feat: 费用状态字段 + 表格排序 + 首页回款提醒 + 回款率着色

- 平台费用新增状态字段(计入费用/暂不计入),默认计入费用
- 费用表格月份列置首,所有表头点击正/倒序排序
- 首页新增回款提醒条(金色渐变,含确收/回款/未回款/回款率)
- 业务表格回款率着色:<30%红,30-80%黄,>=100%绿
- 版本号v1.0.0.5
This commit is contained in:
mac
2026-07-07 18:34:47 +08:00
parent a6acfb2012
commit f9fe32beed
7 changed files with 73 additions and 16 deletions

View File

@@ -5,6 +5,12 @@ function renderExpense() {
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.expenseSort) state.expenseSort = { key: 'expense_month', asc: true };
var sortKey = state.expenseSort.key;
var sortAsc = state.expenseSort.asc ? 1 : -1;
// 筛选状态
var view = state.expenseView || 'total';
@@ -35,30 +41,41 @@ function renderExpense() {
});
}
// 排序
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 = '<option value="">请选择</option>';
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 === state.expenseMonth ? ' selected' : '') + '>' + mv + '</option>';
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 + '"' + (String(qi) === state.expenseQuarter ? ' selected' : '') + '>' + qLabels[qi] + '</option>';
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="setExpenseMonth(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">' + monthOpts + '</select>';
dateSelect = '<span class="text-sm text-slate-500 ml-2">月份:</span><select onchange="setExpenseMonth(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.expenseMonth?' selected':'') + '>请选择</option>' + monthOpts.replace('value="' + state.expenseMonth + '"', 'value="' + state.expenseMonth + '" selected') + '</select>';
} else if (view === 'quarterly') {
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(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 + '</select>';
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(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.expenseQuarter + '"', 'value="' + state.expenseQuarter + '" selected') + '</select>';
}
// Toolbar
@@ -68,21 +85,36 @@ function renderExpense() {
});
toolbar += '</select>' + dateSelect;
// 排序箭头
var sortArrow = function(key) {
var arrow = key === sortKey ? (sortAsc === 1 ? ' ↑' : ' ↓') : '';
return '<span class="text-slate-400 text-xs cursor-pointer" onclick="toggleExpenseSort(\'' + key + '\')">' + arrow + '</span>';
};
// 表格行
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>';
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="openExpenseEdit(' + 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="5" class="p-6 text-center text-slate-400">' + emptyMsg + '</td></tr>';
tableRows = '<tr><td colspan="6" class="p-6 text-center text-slate-400">' + emptyMsg + '</td></tr>';
}
// 模态框
// 模态框 monthOpts for form (with current value preselected)
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="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-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">' + monthOpts + '</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><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>';
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="closeExpenseModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>';
var target = document.querySelector("#financeExpense") || document.querySelector("#financeTabExpense") || document.querySelector("#expense");
if (!target) return;
@@ -94,12 +126,20 @@ function renderExpense() {
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm 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 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="toggleExpenseSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'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.toggleExpenseSort = function(key) {
var cur = state.expenseSort || {};
if (cur.key === key) { cur.asc = !cur.asc; }
else { cur.key = key; cur.asc = true; }
state.expenseSort = cur;
renderExpense();
};
window.setExpenseView = function(v) { state.expenseView = v; renderExpense(); };
window.setExpenseFilter = function(v) { state.expenseFilter = v; renderExpense(); };
window.setExpenseMonth = function(v) { state.expenseMonth = v; renderExpense(); };
@@ -109,6 +149,7 @@ window.openExpenseModal = function() {
var form = document.querySelector("#expenseModal form");
form.reset();
form.querySelector('[name="expense_id"]').value = "";
form.querySelector('[name="status"]').value = "计入费用";
document.querySelector("#expenseModalTitle").textContent = "新增费用";
document.querySelector("#expenseDeleteBtn").classList.add("hidden");
document.querySelector("#expenseModal").classList.remove("hidden");
@@ -130,6 +171,7 @@ window.openExpenseEdit = function(id) {
setVal("amount", r.amount);
setVal("incurred_amount", r.incurred_amount);
setVal("notes", r.notes);
setVal("status", r.status || "计入费用");
document.querySelector("#expenseModalTitle").textContent = "编辑费用";
document.querySelector("#expenseDeleteBtn").classList.remove("hidden");
document.querySelector("#expenseModal").classList.remove("hidden");
@@ -139,9 +181,9 @@ window.saveExpense = async function(event) {
event.preventDefault();
var form = event.target;
var data = Object.fromEntries(new FormData(form).entries());
// 数字字段空值转 0避免线上 MySQL 严格模式报错
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 isEdit = !!data.expense_id;
delete data.expense_id;