Files
opc-manager/static/modules/expense.js
mac 7ab0c71b54 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幂等迁移,启动时自动执行
2026-07-06 17:31:34 +08:00

173 lines
14 KiB
JavaScript

// expense.js — 费用管理模块
function renderExpense() {
var records = state.data.expense || [];
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
var esc = function(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
// 筛选状态
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>';
if (window.lucide) window.lucide.createIcons();
}
window.setExpenseView = function(v) { state.expenseView = v; renderExpense(); };
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.querySelector('[name="expense_id"]').value = "";
document.querySelector("#expenseModalTitle").textContent = "新增费用";
document.querySelector("#expenseDeleteBtn").classList.add("hidden");
document.querySelector("#expenseModal").classList.remove("hidden");
};
window.closeExpenseModal = function() {
document.querySelector("#expenseModal").classList.add("hidden");
};
window.openExpenseEdit = function(id) {
var r = (state.data.expense || []).find(function(x) { return x.id === id; });
if (!r) return;
var form = document.querySelector("#expenseModal form");
form.reset();
var setVal = function(name, val) { var el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val || ""; };
setVal("expense_id", r.id);
setVal("expense_type", r.expense_type);
setVal("expense_month", r.expense_month);
setVal("amount", r.amount);
setVal("incurred_amount", r.incurred_amount);
setVal("notes", r.notes);
document.querySelector("#expenseModalTitle").textContent = "编辑费用";
document.querySelector("#expenseDeleteBtn").classList.remove("hidden");
document.querySelector("#expenseModal").classList.remove("hidden");
};
window.saveExpense = async function(event) {
event.preventDefault();
var form = event.target;
var data = Object.fromEntries(new FormData(form).entries());
var isEdit = !!data.expense_id;
delete data.expense_id;
var id = form.querySelector('[name="expense_id"]').value;
var url = isEdit ? "/api/expense/" + id : "/api/expense";
var method = isEdit ? "PUT" : "POST";
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; }
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
if (bResp.ok) {
var bd = await bResp.json();
state.data = bd;
applyUserTenants();
renderExpense();
}
closeExpenseModal();
};
window.deleteExpenseItem = async function() {
var id = document.querySelector("#expenseModal form [name='expense_id']").value;
if (!id || !confirm("确定删除?")) return;
var resp = await fetch("/api/expense/" + id, { method: "DELETE" });
if (!resp.ok) { alert("删除失败"); return; }
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
if (bResp.ok) {
var bd = await bResp.json();
state.data = bd;
applyUserTenants();
renderExpense();
}
};