费用模块: - 字段精简为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幂等迁移,启动时自动执行
173 lines
14 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
|
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('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') 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('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') 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('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') 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('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') 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();
|
|
}
|
|
};
|