feat: 费用模块重构 + 项目模块优化 + 数据库迁移

费用模块:
- 字段精简为5个:费用类型(下拉6选)、月份(3年36月下拉)、金额、已发生金额、费用说明(textarea)
- 三视图切换(总/季度/月度),按expense_month筛选,汇总卡片实时更新
- 类型筛选功能,Toolbar+汇总+表格三段式布局
- 删除按钮移至关闭按钮左侧,间距8px
- 费用记录删除合计行,列宽固定200px,居中

项目模块:
- 卡片网格6→7列(14卡片7×2满排),配置驱动 METRIC_CARDS
- 新增费用/利润字段,利润=毛利-费用
- 表格行添加费用、利润列,比率列text-xs→text-sm
- 项目流水tab费用与已付列对调,汇总卡片6→1×6
- 修复月度/季度切换时汇总卡片不更新
- 筛选区统一为卡片,去除'筛选:'前缀

数据库:
- expense_records新增expense_type/expense_month/incurred_amount列
- migrations/columns.py幂等迁移,启动时自动执行
This commit is contained in:
mac
2026-07-06 17:31:34 +08:00
parent 8a78c756b7
commit 7ab0c71b54
6 changed files with 189 additions and 90 deletions

View File

@@ -98,6 +98,13 @@ def migrate_add_columns():
"ALTER TABLE project_finances ADD COLUMN contact_phone VARCHAR(50) NOT NULL DEFAULT ''") "ALTER TABLE project_finances ADD COLUMN contact_phone VARCHAR(50) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "project_finances", "other_info", _add_column_if_missing(conn, "project_finances", "other_info",
"ALTER TABLE project_finances ADD COLUMN other_info VARCHAR(500) NOT NULL DEFAULT ''") "ALTER TABLE project_finances ADD COLUMN other_info VARCHAR(500) NOT NULL DEFAULT ''")
# expense_records 字段迁移(重构为费用类型+月份+金额+已发生金额+费用说明)
_add_column_if_missing(conn, "expense_records", "expense_type",
"ALTER TABLE expense_records ADD COLUMN expense_type VARCHAR(100) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "expense_records", "expense_month",
"ALTER TABLE expense_records ADD COLUMN expense_month VARCHAR(20) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "expense_records", "incurred_amount",
"ALTER TABLE expense_records ADD COLUMN incurred_amount DOUBLE NOT NULL DEFAULT 0")
conn.commit() conn.commit()
print("[migrate] 加列迁移完成") print("[migrate] 加列迁移完成")

View File

@@ -150,13 +150,10 @@ def migrate_create_tables():
"""CREATE TABLE IF NOT EXISTS expense_records ( """CREATE TABLE IF NOT EXISTS expense_records (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界', tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界',
project_name VARCHAR(200) NOT NULL DEFAULT '', expense_type VARCHAR(100) NOT NULL DEFAULT '',
purchase_item VARCHAR(300) NOT NULL DEFAULT '', expense_month VARCHAR(20) NOT NULL DEFAULT '',
supplier VARCHAR(200) NOT NULL DEFAULT '',
amount DOUBLE NOT NULL DEFAULT 0, amount DOUBLE NOT NULL DEFAULT 0,
purchase_date VARCHAR(30) NOT NULL DEFAULT '', incurred_amount DOUBLE NOT NULL DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT '待审批',
category VARCHAR(100) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '', notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '', created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT '' updated_at VARCHAR(30) NOT NULL DEFAULT ''

View File

@@ -24,7 +24,7 @@ TABLES = {
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]), "finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]), "tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]), "projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
"expense": ("expense_records", ["project_name", "purchase_item", "supplier", "amount", "purchase_date", "status", "category", "notes", "tenant"]), "expense": ("expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "tenant"]),
} }
# ---------- 鉴权装饰器 ---------- # ---------- 鉴权装饰器 ----------

View File

@@ -1,35 +1,110 @@
// expense.js — 费用管理模块 // expense.js — 费用管理模块
function renderExpense() { function renderExpense() {
const records = state.data.expense || []; var records = state.data.expense || [];
const money = (v) => `¥ ${(Number(v || 0)).toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`; var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
const badge = (s) => { var esc = function(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
const m = { "已审批": "green", "已采购": "blue", "已完成": "slate", "待审批": "amber" }; var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
const c = m[s] || "amber";
return `<span class="text-xs px-2 py-0.5 rounded-full bg-${c}-100 text-${c}-700 font-medium">${s}</span>`;
};
const esc = (s) => (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const totalAmount = records.reduce((s, r) => s + (r.amount || 0), 0); // 筛选状态
var view = state.expenseView || 'total';
var typeFilter = state.expenseFilter || '全部';
var filtered = typeFilter === '全部' ? records : records.filter(function(r) { return r.expense_type === typeFilter; });
// 视图数据预处理
var now = new Date();
var thisYear = now.getFullYear();
var defaultMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
if (!state.expenseMonth) state.expenseMonth = defaultMonth;
if (state.expenseQuarter === '') state.expenseQuarter = String(Math.floor(now.getMonth() / 3));
// 月度筛选
if (view === 'monthly') {
filtered = filtered.filter(function(r) { return r.expense_month === state.expenseMonth; });
}
// 季度筛选
if (view === 'quarterly') {
var q = parseInt(state.expenseQuarter) || 0;
var qRanges = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
var qRange = qRanges[q];
filtered = filtered.filter(function(r) {
var m = parseInt((r.expense_month || '0').substring(5)) || 0;
return qRange.indexOf(m) >= 0;
});
}
var totalAmount = filtered.reduce(function(s, r) { return s + (r.amount || 0); }, 0);
var totalIncurred = filtered.reduce(function(s, r) { return s + (r.incurred_amount || 0); }, 0);
// 月份下拉选项(去年/今年/明年)
var monthOpts = '<option value="">请选择</option>';
for (var yr = thisYear - 1; yr <= thisYear + 1; yr++)
for (var m = 1; m <= 12; m++) {
var mv = yr + '-' + String(m).padStart(2, '0');
monthOpts += '<option value="' + mv + '"' + (mv === state.expenseMonth ? ' selected' : '') + '>' + mv + '</option>';
}
// 季度下拉
var qLabels = ['Q1 (1-3月)','Q2 (4-6月)','Q3 (7-9月)','Q4 (10-12月)'];
var quarterOpts = '';
for (var qi = 0; qi < 4; qi++) {
quarterOpts += '<option value="' + qi + '"' + (String(qi) === state.expenseQuarter ? ' selected' : '') + '>' + qLabels[qi] + '</option>';
}
// 月份/季度日期选择器
var dateSelect = '';
if (view === 'monthly') {
dateSelect = '<span class="text-xs text-slate-500 ml-2">月份:</span><select onchange="setExpenseMonth(this.value)" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">' + monthOpts + '</select>';
} else if (view === 'quarterly') {
dateSelect = '<span class="text-xs text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(this.value)" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts + '</select>';
}
// Toolbar
var toolbar = '<span class="text-xs text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-xs text-slate-500 ml-3">类型:</span><select onchange="setExpenseFilter(this.value)" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
EXPENSE_TYPES.forEach(function(t) {
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
});
toolbar += '</select>' + dateSelect;
// 表格行
var tableRows = '';
if (filtered.length) {
filtered.forEach(function(r) {
tableRows += '<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openExpenseEdit(' + r.id + ')"><td class="p-2 font-medium text-center align-middle" style="width:200px">' + esc(r.expense_type) + '</td><td class="p-2 text-center align-middle" style="width:200px">' + (r.expense_month || '—') + '</td><td class="p-2 text-center align-middle font-medium" style="width:200px">' + money(r.amount) + '</td><td class="p-2 text-center align-middle font-medium" style="width:200px">' + money(r.incurred_amount) + '</td><td class="p-2 text-center align-middle text-slate-500" style="width:200px">' + (esc(r.notes) || '—') + '</td></tr>';
});
} else {
var emptyMsg = view === 'monthly' ? '该月份暂无费用记录' : view === 'quarterly' ? '该季度暂无费用记录' : '暂无费用记录';
tableRows = '<tr><td colspan="5" class="p-6 text-center text-slate-400">' + emptyMsg + '</td></tr>';
}
// 模态框
var modal = '<div id="expenseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeExpenseModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between"><h3 class="text-lg font-bold text-slate-800" id="expenseModalTitle">新增费用</h3><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="expenseDeleteBtn" onclick="deleteExpenseItem()">删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeExpenseModal()"><i data-lucide="x"></i></button></div></div><form onsubmit="saveExpense(event)" class="p-6 grid gap-4" novalidate><input type="hidden" name="expense_id" value=""><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">费用类型 <span class="text-red-500">*</span></span><select name="expense_type" required class="form-ctrl bg-white"><option value="">请选择</option>';
EXPENSE_TYPES.forEach(function(t) { modal += '<option>' + t + '</option>'; });
modal += '</select></label><label class="block"><span class="text-xs text-slate-500 mb-1 block">月份 <span class="text-red-500">*</span></span><select name="expense_month" required class="form-ctrl bg-white">' + monthOpts + '</select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">金额(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label><label class="block"><span class="text-xs text-slate-500 mb-1 block">已发生金额(元)</span><input name="incurred_amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><label class="block"><span class="text-xs text-slate-500 mb-1 block">费用说明</span><textarea name="notes" class="form-ctrl" rows="3" placeholder="备注信息"></textarea></label><div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeExpenseModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>';
document.querySelector("#expense").innerHTML = '<div class="grid gap-4">' +
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
'<div class="grid grid-cols-3 gap-3">' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
'</div>' +
modal +
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold" style="width:200px">费用类型</th><th class="p-2 text-center font-semibold" style="width:200px">月份</th><th class="p-2 text-center font-semibold" style="width:200px">金额</th><th class="p-2 text-center font-semibold" style="width:200px">已发生金额</th><th class="p-2 text-center font-semibold" style="width:200px">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'</div>';
document.querySelector("#expense").innerHTML = `<div class="grid gap-4">
<div class="grid grid-cols-3 gap-3">
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500">费用记录</span><strong class="mt-2 block text-2xl">${records.length}</strong></div>
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500">费用金额</span><strong class="mt-2 block text-2xl">${money(totalAmount)}</strong></div>
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500">待审批</span><strong class="mt-2 block text-2xl">${records.filter(r => r.status === '待审批').length}</strong></div>
</div>
<div class="flex justify-between items-center">
<h3 class="font-bold text-slate-700">费用列表 <span class="text-slate-400 font-normal">(${records.length})</span></h3>
<button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button>
</div>
<div id="purchaseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeExpenseModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between"><h3 class="text-lg font-bold text-slate-800" id="purchaseModalTitle">新增费用</h3><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeExpenseModal()"><i data-lucide="x"></i></button></div><form onsubmit="saveExpense(event)" class="p-6 grid gap-4" novalidate><input type="hidden" name="expense_id" id="purchase-id-input" value=""><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">项目名称 <span class="text-red-500">*</span></span><input name="project_name" required class="form-ctrl" placeholder="请输入项目名称"></label><label class="block"><span class="text-xs text-slate-500 mb-1 block">采购内容 <span class="text-red-500">*</span></span><input name="purchase_item" required class="form-ctrl" placeholder="请输入采购内容"></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">供应商</span><input name="supplier" class="form-ctrl" placeholder="请输入供应商"></label><label class="block"><span class="text-xs text-slate-500 mb-1 block">金额(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">采购日期</span><input name="purchase_date" type="date" class="form-ctrl"></label><label class="block"><span class="text-xs text-slate-500 mb-1 block">状态</span><select name="status" class="form-ctrl bg-white"><option>待审批</option><option>已审批</option><option>已采购</option><option>已完成</option></select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">分类</span><input name="category" class="form-ctrl" placeholder="如:办公用品、设备等"></label></div><div class="grid"><label class="block"><span class="text-xs text-slate-500 mb-1 block">备注</span><textarea name="notes" class="form-ctrl" rows="2" placeholder="备注信息"></textarea></label></div><div class="flex justify-end gap-2 pt-2 border-t border-slate-100"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="purchaseDeleteBtn" onclick="deleteExpenseItem()" type="button">删除</button><button class="btn btn-ghost btn-sm" onclick="closeExpenseModal()" type="button">取消</button><button class="btn btn-primary btn-sm" type="submit">保存</button></div></form></div></div>
<div class="overflow-x-auto card p-4"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-left font-semibold">项目名称</th><th class="p-2 text-left font-semibold">采购内容</th><th class="p-2 text-left font-semibold">供应商</th><th class="p-2 text-right font-semibold">金额</th><th class="p-2 text-center font-semibold">采购日期</th><th class="p-2 text-center font-semibold">状态</th><th class="p-2 text-left font-semibold">分类</th><th class="p-2 text-left font-semibold">备注</th></tr></thead><tbody>${records.length ? records.map(r => `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openExpenseEdit(${r.id})"><td class="p-2 font-medium">${esc(r.project_name)}</td><td class="p-2">${esc(r.purchase_item)}</td><td class="p-2">${esc(r.supplier)}</td><td class="p-2 text-right font-medium">${money(r.amount)}</td><td class="p-2 text-center text-xs">${r.purchase_date || '—'}</td><td class="p-2 text-center">${badge(r.status)}</td><td class="p-2">${esc(r.category)}</td><td class="p-2 text-xs text-slate-500">${esc(r.notes) || '—'}</td></tr>`).join("") : '<tr><td colspan="8" class="p-6 text-center text-slate-400">暂无费用记录</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2" colspan="3">合计(${records.length} 条)</td><td class="p-2 text-right">${money(totalAmount)}</td><td class="p-2" colspan="4"></td></tr></tbody></table></div>
</div>`;
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
} }
window.openExpenseModal = () => { window.setExpenseView = function(v) { state.expenseView = v; renderExpense(); };
const form = document.querySelector("#expenseModal form"); window.setExpenseFilter = function(v) { state.expenseFilter = v; renderExpense(); };
window.setExpenseMonth = function(v) { state.expenseMonth = v; renderExpense(); };
window.setExpenseQuarter = function(v) { state.expenseQuarter = v; renderExpense(); };
window.openExpenseModal = function() {
var form = document.querySelector("#expenseModal form");
form.reset(); form.reset();
form.querySelector('[name="expense_id"]').value = ""; form.querySelector('[name="expense_id"]').value = "";
document.querySelector("#expenseModalTitle").textContent = "新增费用"; document.querySelector("#expenseModalTitle").textContent = "新增费用";
@@ -37,49 +112,44 @@ window.openExpenseModal = () => {
document.querySelector("#expenseModal").classList.remove("hidden"); document.querySelector("#expenseModal").classList.remove("hidden");
}; };
window.closeExpenseModal = () => { window.closeExpenseModal = function() {
document.querySelector("#expenseModal").classList.add("hidden"); document.querySelector("#expenseModal").classList.add("hidden");
}; };
window.openExpenseEdit = (id) => { window.openExpenseEdit = function(id) {
const r = (state.data.expense || []).find(x => x.id === id); var r = (state.data.expense || []).find(function(x) { return x.id === id; });
if (!r) return; if (!r) return;
const form = document.querySelector("#expenseModal form"); var form = document.querySelector("#expenseModal form");
form.reset(); form.reset();
const setVal = (name, val) => { const el = form.querySelector(`[name="${name}"]`); if (el) el.value = val || ""; }; var setVal = function(name, val) { var el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val || ""; };
setVal("expense_id", r.id); setVal("expense_id", r.id);
setVal("project_name", r.project_name); setVal("expense_type", r.expense_type);
setVal("purchase_item", r.purchase_item); setVal("expense_month", r.expense_month);
setVal("supplier", r.supplier);
setVal("amount", r.amount); setVal("amount", r.amount);
setVal("purchase_date", r.purchase_date); setVal("incurred_amount", r.incurred_amount);
setVal("status", r.status);
setVal("category", r.category);
setVal("notes", r.notes); setVal("notes", r.notes);
document.querySelector("#expenseModalTitle").textContent = "编辑费用"; document.querySelector("#expenseModalTitle").textContent = "编辑费用";
document.querySelector("#expenseDeleteBtn").classList.remove("hidden"); document.querySelector("#expenseDeleteBtn").classList.remove("hidden");
document.querySelector("#expenseModal").classList.remove("hidden"); document.querySelector("#expenseModal").classList.remove("hidden");
}; };
window.saveExpense = async (event) => { window.saveExpense = async function(event) {
event.preventDefault(); event.preventDefault();
const form = event.target; var form = event.target;
const data = Object.fromEntries(new FormData(form).entries()); var data = Object.fromEntries(new FormData(form).entries());
const isEdit = !!data.expense_id; var isEdit = !!data.expense_id;
delete data.expense_id; delete data.expense_id;
const url = isEdit var id = form.querySelector('[name="expense_id"]').value;
? `/api/expense/${form.querySelector('[name="expense_id"]').value}` var url = isEdit ? "/api/expense/" + id : "/api/expense";
: "/api/expense"; var method = isEdit ? "PUT" : "POST";
const method = isEdit ? "PUT" : "POST";
const resp = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { ...data, tenant: state.tenant } }) }); var resp = await fetch(url, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: Object.assign({}, data, { tenant: state.tenant }) }) });
if (!resp.ok) { alert("保存失败"); return; } if (!resp.ok) { alert("保存失败"); return; }
// Refresh data var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
const bResp = await fetch(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
if (bResp.ok) { if (bResp.ok) {
const bd = await bResp.json(); var bd = await bResp.json();
state.data = bd; state.data = bd;
applyUserTenants(); applyUserTenants();
renderExpense(); renderExpense();
@@ -87,17 +157,16 @@ window.saveExpense = async (event) => {
closeExpenseModal(); closeExpenseModal();
}; };
window.deleteExpenseItem = async () => { window.deleteExpenseItem = async function() {
const id = document.querySelector("#expenseModal form [name='expense_id']").value; var id = document.querySelector("#expenseModal form [name='expense_id']").value;
if (!id || !confirm("确定删除?")) return; if (!id || !confirm("确定删除?")) return;
const resp = await fetch(`/api/expense/${id}`, { method: "DELETE" }); var resp = await fetch("/api/expense/" + id, { method: "DELETE" });
if (!resp.ok) { alert("删除失败"); return; } if (!resp.ok) { alert("删除失败"); return; }
const bResp = await fetch(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`); var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
if (bResp.ok) { if (bResp.ok) {
const bd = await bResp.json(); var bd = await bResp.json();
state.data = bd; state.data = bd;
applyUserTenants(); applyUserTenants();
renderExpense(); renderExpense();
} }
closeExpenseModal();
}; };

View File

@@ -164,7 +164,7 @@ function renderFinance() {
</div> </div>
</div> </div>
<div id="financeTabBudget" class="hidden"> <div id="financeTabBudget" class="hidden">
<div class="grid grid-cols-5 gap-3 mb-4" id="budgetSummary"> <div class="grid grid-cols-6 gap-3 mb-4" id="budgetSummary">
<div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100"> <div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100">
<p class="text-xs text-blue-600 font-medium">总确收</p> <p class="text-xs text-blue-600 font-medium">总确收</p>
<p class="text-lg font-bold text-blue-700" id="budgetTotalRev">¥0</p> <p class="text-lg font-bold text-blue-700" id="budgetTotalRev">¥0</p>
@@ -185,9 +185,13 @@ function renderFinance() {
<p class="text-xs text-purple-600 font-medium">总已付</p> <p class="text-xs text-purple-600 font-medium">总已付</p>
<p class="text-lg font-bold text-purple-700" id="budgetTotalPaid">¥0</p> <p class="text-lg font-bold text-purple-700" id="budgetTotalPaid">¥0</p>
</div> </div>
<div class="bg-orange-50 rounded-lg p-3 text-center border border-orange-100" id="budgetTotalExpenseCard">
<p class="text-xs text-orange-600 font-medium">总费用</p>
<p class="text-lg font-bold text-orange-700" id="budgetTotalExpense">¥0</p>
</div>
</div> </div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="budgetTable"> <table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="budgetTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead> <thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="budgetTbody"></tbody> <tbody id="budgetTbody"></tbody>
</table> </table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i></button> <button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i></button>
@@ -231,16 +235,18 @@ function renderFinance() {
{ label: '执行率', value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' }, { label: '执行率', value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '毛利', value: ctx => money(ctx.sumGross), color: 'text-green-700' }, { label: '毛利', value: ctx => money(ctx.sumGross), color: 'text-green-700' },
{ label: '毛利率', value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' }, { label: '毛利率', value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '费用', value: ctx => money(ctx.sumExpense), color: 'text-orange-700' },
{ label: '已回款', value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' }, { label: '已回款', value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
{ label: '回款率', value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' }, { label: '回款率', value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '应付', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' }, { label: '应付', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '已付', value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' }, { label: '已付', value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '付款率', value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' }, { label: '付款率', value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '现金流', value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null }, { label: '现金流', value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '利润', value: ctx => { const v = ctx.sumGross - ctx.sumExpense; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
]; ];
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => { const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt, emptyText) => {
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt }; const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt };
const cards = METRIC_CARDS.map(c => { const cards = METRIC_CARDS.map(c => {
const v = c.value(ctx); const v = c.value(ctx);
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color]; return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
@@ -258,16 +264,18 @@ function renderFinance() {
'<th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th>' + '<th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th>' +
'<th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th>' + '<th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th>' +
'<th class="p-2 text-center font-semibold align-middle">付款率</th>' + '<th class="p-2 text-center font-semibold align-middle">付款率</th>' +
'<th class="p-2 text-center font-semibold align-middle text-orange-600">费用</th>' +
'<th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th>' + '<th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th>' +
'<th class="p-2 text-center font-semibold align-middle">利润</th>' +
'</tr></thead>'; '</tr></thead>';
const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="13" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`; const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="15" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
return card(`<div class="grid grid-cols-6 gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs text-slate-500">' + l + '</span><strong class="mt-2 block text-xl ' + c + '">' + v + '</strong></div>').join("")}</div>`, "p-4") + return card(`<div class="grid grid-cols-7 gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs text-slate-500">' + l + '</span><strong class="mt-2 block text-xl ' + c + '">' + v + '</strong></div>').join("")}</div>`, "p-4") +
card(`<div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4"); card(`<div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
}; };
const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>'; const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>';
const tdRow = (pf, rev, payment, cost, paid, gross) => { const tdRow = (pf, rev, payment, cost, paid, gross, expense) => {
const cashflow = payment - paid; const cashflow = payment - paid;
const exe = rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) + '%' : '<span class="text-slate-300">—</span>'; const exe = rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) + '%' : '<span class="text-slate-300">—</span>';
const payR = rev && payment ? Math.round(payment / rev * 100) + '%' : '<span class="text-slate-300">—</span>'; const payR = rev && payment ? Math.round(payment / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
@@ -276,7 +284,10 @@ function renderFinance() {
const st = pf.status === '已签约' ? '<span class="text-green-600">已签约</span>' : '<span class="text-amber-600">待签约</span>'; const st = pf.status === '已签约' ? '<span class="text-green-600">已签约</span>' : '<span class="text-amber-600">待签约</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'; const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600'; const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${st}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="p-2 text-center align-middle text-xs">${exe}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center align-middle text-xs">${grossR}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center align-middle text-xs">${payR}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="p-2 text-center align-middle text-xs">${paidR}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr>`; const profit = gross - expense;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${st}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="p-2 text-center align-middle text-sm">${exe}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center align-middle text-sm">${grossR}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center align-middle text-sm">${payR}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="p-2 text-center align-middle text-sm">${paidR}</td><td class="p-2 text-center text-orange-700 align-middle">${fmtNoUnit(expense)}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
}; };
if (state.finView === 'monthly') { if (state.finView === 'monthly') {
@@ -286,7 +297,7 @@ function renderFinance() {
if (!state.finMonth) state.finMonth = defaultMonth; if (!state.finMonth) state.finMonth = defaultMonth;
const selMonth = state.finMonth; const selMonth = state.finMonth;
const rows = []; const rows = [];
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0; let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0, sumExpense = 0;
allPfs.forEach(pf => { allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {} let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
const b = bd.find(x => (x.month || "") === selMonth) || {}; const b = bd.find(x => (x.month || "") === selMonth) || {};
@@ -295,13 +306,14 @@ function renderFinance() {
const cost = Math.round(parseFloat(b.cost || 0) || 0); const cost = Math.round(parseFloat(b.cost || 0) || 0);
const paid = Math.round(parseFloat(b.paid || 0) || 0); const paid = Math.round(parseFloat(b.paid || 0) || 0);
const gross = Math.round(parseFloat(b.gross || 0) || 0); const gross = Math.round(parseFloat(b.gross || 0) || 0);
if (!rev && !payment && !cost && !paid && !gross) return; const expense = Math.round(parseFloat(b.expense || 0) || 0);
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross; if (!rev && !payment && !cost && !paid && !gross && !expense) return;
rows.push(tdRow(pf, rev, payment, cost, paid, gross)); sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross; sumExpense += expense;
rows.push(tdRow(pf, rev, payment, cost, paid, gross, expense));
}); });
const signTotal = Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0)); const signTotal = Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约" && (x.sign_month || "").substring(0, 7) === selMonth).length; const signCnt = allPfs.filter(x => x.status === "已签约" && (x.sign_month || "").substring(0, 7) === selMonth).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据'); return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt, '该月份暂无数据');
} }
if (state.finView === 'quarterly') { if (state.finView === 'quarterly') {
@@ -319,7 +331,7 @@ function renderFinance() {
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {} try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {}
return total; return total;
}; };
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0; let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0, sumExpense = 0;
const rows = []; const rows = [];
allPfs.forEach(pf => { allPfs.forEach(pf => {
const rev = sumBudget(pf, "rev"); const rev = sumBudget(pf, "rev");
@@ -327,33 +339,34 @@ function renderFinance() {
const cost = sumBudget(pf, "cost"); const cost = sumBudget(pf, "cost");
const paid = sumBudget(pf, "paid"); const paid = sumBudget(pf, "paid");
const gross = sumBudget(pf, "gross"); const gross = sumBudget(pf, "gross");
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross; const expense = sumBudget(pf, "expense");
rows.push(tdRow(pf, rev, payment, cost, paid, gross)); sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross; sumExpense += expense;
rows.push(tdRow(pf, rev, payment, cost, paid, gross, expense));
}); });
const signTotal = Math.round(allPfs.filter(x => { const m = parseInt((x.sign_month || "0").substring(5)) || 0; return qRange.includes(m); }).reduce((a, p) => a + (p.sign_amount || 0), 0)); const signTotal = Math.round(allPfs.filter(x => { const m = parseInt((x.sign_month || "0").substring(5)) || 0; return qRange.includes(m); }).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约" && qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length; const signCnt = allPfs.filter(x => x.status === "已签约" && qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据'); return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt, '该季度暂无数据');
} }
// 总视图 // 总视图
const calcTotals = (pf) => { const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {} let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, cost = 0, gross = 0; let rev = 0, payment = 0, cost = 0, gross = 0, expense = 0;
budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; cost += parseFloat(b.cost || 0) || 0; }); budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; cost += parseFloat(b.cost || 0) || 0; expense += parseFloat(b.expense || 0) || 0; });
const paid = parseFloat(pf.total_paid) || 0; const paid = parseFloat(pf.total_paid) || 0;
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) }; return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross), expense: Math.round(expense) };
}; };
const allPfs = pfs.filter(x => x.status === state.finFilter); const allPfs = pfs.filter(x => x.status === state.finFilter);
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0; let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0, sumExpense = 0;
const rows = []; const rows = [];
allPfs.forEach(pf => { allPfs.forEach(pf => {
const t = calcTotals(pf); const t = calcTotals(pf);
sumRev += t.rev; sumPay += t.payment; sumCost += t.cost; sumPaid += t.paid; sumGross += t.gross; sumRev += t.rev; sumPay += t.payment; sumCost += t.cost; sumPaid += t.paid; sumGross += t.gross; sumExpense += t.expense;
rows.push(tdRow(pf, t.rev, t.payment, t.cost, t.paid, t.gross)); rows.push(tdRow(pf, t.rev, t.payment, t.cost, t.paid, t.gross, t.expense));
}); });
const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0)); const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约").length; const signCnt = allPfs.filter(x => x.status === "已签约").length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据'); return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, sumExpense, signTotal, signCnt, '暂无数据');
})()} })()}
</div>`; </div>`;
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
@@ -374,7 +387,7 @@ window.openFinanceModal = () => {
modal.classList.remove("hidden"); modal.classList.remove("hidden");
}; };
window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '', paid = '') => { window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '', paid = '', expense = '') => {
const tbody = document.querySelector("#budgetTbody"); const tbody = document.querySelector("#budgetTbody");
if (!tbody) return; if (!tbody) return;
const row = document.createElement("tr"); const row = document.createElement("tr");
@@ -384,6 +397,7 @@ window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = ''
<td><input name="budget_payment[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${payment}" oninput="updateBudgetSummary()"></td> <td><input name="budget_payment[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${payment}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_cost[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${cost}" oninput="updateBudgetSummary()"></td> <td><input name="budget_cost[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${cost}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_paid[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${paid}" oninput="updateBudgetSummary()"></td> <td><input name="budget_paid[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${paid}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_expense[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${expense}" oninput="updateBudgetSummary()"></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove();updateBudgetSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`; <td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove();updateBudgetSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
tbody.appendChild(row); tbody.appendChild(row);
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
@@ -394,23 +408,27 @@ window.updateBudgetSummary = () => {
const grossEl = document.querySelector("#budgetTotalGross"); const grossEl = document.querySelector("#budgetTotalGross");
const paymentEl = document.querySelector("#budgetTotalPayment"); const paymentEl = document.querySelector("#budgetTotalPayment");
const costEl = document.querySelector("#budgetTotalCost"); const costEl = document.querySelector("#budgetTotalCost");
const expenseEl = document.querySelector("#budgetTotalExpense");
const paidEl = document.querySelector("#budgetTotalPaid"); const paidEl = document.querySelector("#budgetTotalPaid");
if (!revEl || !grossEl) return; if (!revEl || !grossEl) return;
const revInputs = document.querySelectorAll('[name="budget_rev[]"]'); const revInputs = document.querySelectorAll('[name="budget_rev[]"]');
const grossInputs = document.querySelectorAll('[name="budget_gross[]"]'); const grossInputs = document.querySelectorAll('[name="budget_gross[]"]');
const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]'); const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]');
const costInputs = document.querySelectorAll('[name="budget_cost[]"]'); const costInputs = document.querySelectorAll('[name="budget_cost[]"]');
const expenseInputs = document.querySelectorAll('[name="budget_expense[]"]');
const paidInputs = document.querySelectorAll('[name="budget_paid[]"]'); const paidInputs = document.querySelectorAll('[name="budget_paid[]"]');
let totalRev = 0, totalGross = 0, totalPayment = 0, totalCost = 0, totalPaid = 0; let totalRev = 0, totalGross = 0, totalPayment = 0, totalCost = 0, totalExpense = 0, totalPaid = 0;
revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; }); revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; });
grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; }); grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; });
paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; }); paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; });
costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; }); costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; });
expenseInputs.forEach(el => { totalExpense += parseFloat(el.value) || 0; });
paidInputs.forEach(el => { totalPaid += parseFloat(el.value) || 0; }); paidInputs.forEach(el => { totalPaid += parseFloat(el.value) || 0; });
revEl.textContent = money(totalRev); revEl.textContent = money(totalRev);
grossEl.textContent = money(totalGross); grossEl.textContent = money(totalGross);
if (paymentEl) paymentEl.textContent = money(totalPayment); if (paymentEl) paymentEl.textContent = money(totalPayment);
if (costEl) costEl.textContent = money(totalCost); if (costEl) costEl.textContent = money(totalCost);
if (expenseEl) expenseEl.textContent = money(totalExpense);
if (paidEl) paidEl.textContent = money(totalPaid); if (paidEl) paidEl.textContent = money(totalPaid);
}; };
@@ -419,7 +437,7 @@ window.initBudgetTable = (budgetData) => {
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = ""; tbody.innerHTML = "";
const rows = budgetData || []; const rows = budgetData || [];
rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || '', r.paid || '')); rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || '', r.paid || '', r.expense || ''));
setTimeout(() => updateBudgetSummary(), 50); setTimeout(() => updateBudgetSummary(), 50);
}; };
@@ -687,6 +705,7 @@ window.createFinance = async (event) => {
const grosses = form.querySelectorAll('[name="budget_gross[]"]'); const grosses = form.querySelectorAll('[name="budget_gross[]"]');
const payments = form.querySelectorAll('[name="budget_payment[]"]'); const payments = form.querySelectorAll('[name="budget_payment[]"]');
const costs = form.querySelectorAll('[name="budget_cost[]"]'); const costs = form.querySelectorAll('[name="budget_cost[]"]');
const expenses = form.querySelectorAll('[name="budget_expense[]"]');
const paids = form.querySelectorAll('[name="budget_paid[]"]'); const paids = form.querySelectorAll('[name="budget_paid[]"]');
const budgetRows = []; const budgetRows = [];
let totalRev = 0, totalGross = 0, totalPaidFromBudget = 0; let totalRev = 0, totalGross = 0, totalPaidFromBudget = 0;
@@ -697,8 +716,9 @@ window.createFinance = async (event) => {
const gross = parseFloat(grosses[i].value) || 0; const gross = parseFloat(grosses[i].value) || 0;
const payment = parseFloat(payments[i].value) || 0; const payment = parseFloat(payments[i].value) || 0;
const cost = parseFloat(costs[i].value) || 0; const cost = parseFloat(costs[i].value) || 0;
const expense = parseFloat(expenses[i].value) || 0;
const paid = parseFloat(paids[i].value) || 0; const paid = parseFloat(paids[i].value) || 0;
budgetRows.push({ month: m, rev, gross, payment, cost, paid }); budgetRows.push({ month: m, rev, gross, payment, cost, expense, paid });
totalRev += rev; totalRev += rev;
totalGross += gross; totalGross += gross;
totalPaidFromBudget += paid; totalPaidFromBudget += paid;
@@ -707,10 +727,12 @@ window.createFinance = async (event) => {
data.total_rev = totalRev; data.total_rev = totalRev;
data.total_gross = totalGross; data.total_gross = totalGross;
data.total_paid = totalPaidFromBudget; data.total_paid = totalPaidFromBudget;
let totalPayment = 0, totalCost = 0; let totalPayment = 0, totalCost = 0, totalExpense = 0;
for (const r of budgetRows) { totalPayment += r.payment; totalCost += r.cost; } for (const r of budgetRows) { totalPayment += r.payment; totalCost += r.cost; totalExpense += r.expense; }
data.total_payment = totalPayment; data.total_payment = totalPayment;
data.total_cost = totalCost; data.total_cost = totalCost;
data.total_expense = totalExpense;
data.total_cost = totalCost;
// 收集任务管理数据 // 收集任务管理数据
const taskTypeInputs = form.querySelectorAll('[name="task_type[]"]'); const taskTypeInputs = form.querySelectorAll('[name="task_type[]"]');
const taskCountInputs = form.querySelectorAll('[name="task_count[]"]'); const taskCountInputs = form.querySelectorAll('[name="task_count[]"]');

View File

@@ -16,6 +16,10 @@ const state = {
chart3: null, chart3: null,
productPlatform: "all", productPlatform: "all",
uploadTasks: [], uploadTasks: [],
expenseFilter: "全部",
expenseView: "total",
expenseMonth: "",
expenseQuarter: "",
}; };
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`; const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;