Files
opc-manager/static/modules/plan_expense.js
mac 7bdde246bc feat: 平台费用Tab切换+汇总筛选对换+卡片样式统一
- 平台费用类型改为Tab按钮切换(人力成本|研发费用|奖金|平台采购|通用费用|其他)
- 平台费用计划改名为平台费用
- 项目管理和平台费用: 汇总区在上筛选区在下
- 平台费用卡片样式对齐项目管理(紧凑px-3 py-1.5)
- 视图下拉框放在Tab按钮左边
- 版本号 v1.2.0.16
2026-07-10 21:43:19 +08:00

227 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// expense.js — 费用管理模块
function renderPlanExpense() {
var records = state.data.planExpense || [];
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
var esc = function(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
var STATUS_OPTIONS = ['计入费用','暂不计入'];
// 排序状态
if (!state.planExpSort) state.planExpSort = { key: 'expense_month', asc: true };
var sortKey = state.planExpSort.key;
var sortAsc = state.planExpSort.asc ? 1 : -1;
// 筛选状态
var view = state.planExpView || 'total';
var typeFilter = state.planExpFilter || '全部';
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.planExpMonth) state.planExpMonth = defaultMonth;
if (state.planExpQuarter === '') state.planExpQuarter = String(Math.floor(now.getMonth() / 3));
// 月度筛选
if (view === 'monthly') {
filtered = filtered.filter(function(r) { return r.expense_month === state.planExpMonth; });
}
// 季度筛选
if (view === 'quarterly') {
var q = parseInt(state.planExpQuarter) || 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;
});
}
// 排序
filtered.sort(function(a, b) {
var va = a[sortKey] || '', vb = b[sortKey] || '';
if (sortKey === 'amount' || sortKey === 'incurred_amount') {
va = parseFloat(va) || 0; vb = parseFloat(vb) || 0;
}
if (va < vb) return -1 * sortAsc;
if (va > vb) return 1 * sortAsc;
return 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 = '';
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 + '</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 + '">' + qLabels[qi] + '</option>';
}
// 月份/季度日期选择器
var dateSelect = '';
if (view === 'monthly') {
dateSelect = '<span class="text-sm text-slate-500 ml-2">月份:</span><select onchange="setPlanExpMonth(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&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=""' + (!state.planExpMonth?' selected':'') + '>请选择</option>' + monthOpts.replace('value="' + state.planExpMonth + '"', 'value="' + state.planExpMonth + '" selected') + '</select>';
} else if (view === 'quarterly') {
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setPlanExpQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&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.replace('value="' + state.planExpQuarter + '"', 'value="' + state.planExpQuarter + '" selected') + '</select>';
}
// 类型 Tab 按钮
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setPlanExpFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
EXPENSE_TYPES.forEach(function(t) {
var cnt = records.filter(function(r){return r.expense_type===t}).length;
typeTabs += '<button onclick="setPlanExpFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
});
typeTabs += '</div>';
// Toolbar视图+日期选择器)
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&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>' + dateSelect;
// 排序箭头
var sortArrow = function(key) {
var arrow = key === sortKey ? (sortAsc === 1 ? ' ↑' : ' ↓') : '';
return '<span class="text-slate-400 text-xs cursor-pointer" onclick="togglePlanExpSort(\'' + key + '\')">' + arrow + '</span>';
};
// 表格行
var tableRows = '';
if (filtered.length) {
filtered.forEach(function(r) {
var statusCls = r.status === '暂不计入' ? 'text-amber-600' : 'text-green-600';
tableRows += '<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPlanExpEdit(' + r.id + ')"><td class="p-2 text-center align-middle font-medium">' + (r.expense_month || '—') + '</td><td class="p-2 font-medium text-center align-middle">' + esc(r.expense_type) + '</td><td class="p-2 text-center align-middle font-semibold ' + statusCls + '">' + esc(r.status || '计入费用') + '</td><td class="p-2 text-center align-middle font-medium">' + money(r.amount) + '</td><td class="p-2 text-center align-middle font-medium">' + money(r.incurred_amount) + '</td><td class="p-2 text-center align-middle text-slate-500">' + (esc(r.notes) || '—') + '</td></tr>';
});
} else {
var emptyMsg = view === 'monthly' ? '该月份暂无费用记录' : view === 'quarterly' ? '该季度暂无费用记录' : '暂无费用记录';
tableRows = '<tr><td colspan="6" class="p-6 text-center text-slate-400">' + emptyMsg + '</td></tr>';
}
// 模态框 monthOpts for form (with current value preselected)
var modalMonthOpts = '<option value="">请选择</option>';
for (var yr2 = thisYear - 1; yr2 <= thisYear + 1; yr2++)
for (var m2 = 1; m2 <= 12; m2++) {
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
modalMonthOpts += '<option value="' + mv2 + '">' + mv2 + '</option>';
}
var statusOpts = STATUS_OPTIONS.map(function(s) { return '<option value="' + s + '">' + s + '</option>'; }).join('');
var modal = '<div id="planExpenseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanExpModal()"><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="planExpenseModalTitle">新增费用</h3><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planExpDeleteBtn" onclick="deletePlanExpItem()">删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanExpModal()"><i data-lucide="x"></i></button></div></div><form onsubmit="savePlanExpense(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-sm 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-sm text-slate-500 mb-1 block">月份 <span class="text-red-500">*</span></span><select name="expense_month" required class="form-ctrl bg-white">' + modalMonthOpts + '</select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm 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-sm 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-sm text-slate-500 mb-1 block">状态</span><select name="status" class="form-ctrl bg-white">' + statusOpts + '</select></label><label class="block"><span class="text-sm 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="closePlanExpModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>';
var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
if (!target) return;
target.innerHTML = '<div class="grid gap-4">' +
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">总费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">现金支出</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
'</div>' +
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
modal +
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">现金支出' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'</div>';
if (window.lucide) window.lucide.createIcons();
}
window.togglePlanExpSort = function(key) {
var cur = state.planExpSort || {};
if (cur.key === key) { cur.asc = !cur.asc; }
else { cur.key = key; cur.asc = true; }
state.planExpSort = cur;
renderPlanExpense();
};
window.setPlanExpView = function(v) { state.planExpView = v; renderPlanExpense(); };
window.setPlanExpFilter = function(v) { state.planExpFilter = v; renderPlanExpense(); };
window.setPlanExpMonth = function(v) { state.planExpMonth = v; renderPlanExpense(); };
window.setPlanExpQuarter = function(v) { state.planExpQuarter = v; renderPlanExpense(); };
window.openPlanExpModal = function() {
var form = document.querySelector("#planExpenseModal form");
form.reset();
form.querySelector('[name="expense_id"]').value = "";
form.querySelector('[name="status"]').value = "计入费用";
document.querySelector("#planExpenseModalTitle").textContent = "新增费用";
document.querySelector("#planExpDeleteBtn").classList.add("hidden");
document.querySelector("#planExpenseModal").classList.remove("hidden");
};
window.closePlanExpModal = function() {
document.querySelector("#planExpenseModal").classList.add("hidden");
};
window.openPlanExpEdit = function(id) {
var r = (state.data.planExpense || []).find(function(x) { return x.id === id; });
if (!r) return;
var form = document.querySelector("#planExpenseModal 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);
setVal("status", r.status || "计入费用");
document.querySelector("#planExpenseModalTitle").textContent = "编辑费用";
document.querySelector("#planExpDeleteBtn").classList.remove("hidden");
document.querySelector("#planExpenseModal").classList.remove("hidden");
};
window.savePlanExpense = async function(event) {
event.preventDefault();
var form = event.target;
var data = Object.fromEntries(new FormData(form).entries());
if (data.amount === '' || data.amount === undefined) data.amount = 0;
if (data.incurred_amount === '' || data.incurred_amount === undefined) data.incurred_amount = 0;
if (!data.status) data.status = '计入费用';
var id = data.expense_id;
delete data.expense_id;
data.tenant = state.tenant;
try {
if (id) {
await api("/api/planExpense/" + id, { method: "PUT", body: JSON.stringify({ data }) });
} else {
await api("/api/planExpense", { method: "POST", body: JSON.stringify({ data }) });
}
} catch (error) {
toast("保存失败:" + error.message, "error");
return;
}
closePlanExpModal();
try {
state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant));
renderPlanExpense();
} catch (e) { /* non-critical */ }
};
window.deletePlanExpItem = async function() {
var id = document.querySelector("#planExpenseModal form [name='expense_id']").value;
if (!id || !confirm("确定删除?")) return;
try {
await api("/api/planExpense/" + id, { method: "DELETE" });
} catch (error) {
toast("删除失败:" + error.message, "error");
return;
}
closePlanExpModal();
try {
state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant));
renderPlanExpense();
} catch (e) { /* non-critical */ }
};