|
|
|
|
@@ -25,12 +25,10 @@ function renderFinance() {
|
|
|
|
|
const monthLabels = displayMonths.map(d => d.label);
|
|
|
|
|
|
|
|
|
|
const signed = pfs.filter(x => x.status === "已签约");
|
|
|
|
|
const inContract = pfs.filter(x => x.status === "流程中");
|
|
|
|
|
const pending = pfs.filter(x => x.status === "待签约");
|
|
|
|
|
const pending = pfs.filter(x => x.status === "待签约");
|
|
|
|
|
const sumSign = Math.round(signed.reduce((s,x) => s + (x.sign_amount||0), 0));
|
|
|
|
|
const sumPending = Math.round(pending.reduce((s,x) => s + (x.sign_amount||0), 0));
|
|
|
|
|
const sumContract = Math.round(inContract.reduce((s,x) => s + (x.sign_amount||0), 0));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const monthRev = months.map(m => {
|
|
|
|
|
return signed.reduce((s, pf) => {
|
|
|
|
|
let budget = [];
|
|
|
|
|
@@ -103,11 +101,23 @@ function renderFinance() {
|
|
|
|
|
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">${esc(pf.customer_name)}</td>${signMonthCell}<td class="p-2 text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const finHeaderBase = `<button class="btn btn-sm ${state.finView === 'overview' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="overview" onclick="setFinView('overview')">总视图</button><button class="btn btn-sm ${state.finView === 'monthly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="monthly" onclick="setFinView('monthly')">月度</button><button class="btn btn-sm ${state.finView === 'quarterly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="quarterly" onclick="setFinView('quarterly')">季度</button><span class="text-slate-300 mx-0.5">|</span><span class="text-xs text-slate-400">筛选:</span><span class="text-xs text-slate-500">状态:</span><select onchange="state.finFilter=this.value;renderFinance()" 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="已签约" ${state.finFilter==='已签约'?'selected':''}>已签约 (${pfs.filter(x=>x.status==='已签约').length})</option><option value="流程中" ${state.finFilter==='流程中'?'selected':''}>流程中 (${pfs.filter(x=>x.status==='流程中').length})</option><option value="待签约" ${state.finFilter==='待签约'?'selected':''}>待签约 (${pfs.filter(x=>x.status==='待签约').length})</option></select>`;
|
|
|
|
|
const now2 = new Date();
|
|
|
|
|
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
|
|
|
|
|
if (!state.finMonth) state.finMonth = defaultMonth2;
|
|
|
|
|
if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3);
|
|
|
|
|
const monthSet2 = new Set([defaultMonth2]);
|
|
|
|
|
pfs.forEach(pf => { let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {} bd.forEach(b => { if (b.month) monthSet2.add(b.month); }); });
|
|
|
|
|
const allMonths = [...monthSet2].sort().reverse();
|
|
|
|
|
const qLabels2 = ["Q1 (1-3月)","Q2 (4-6月)","Q3 (7-9月)","Q4 (10-12月)"];
|
|
|
|
|
const toolMonthSelect = allMonths.map(m => '<option value="'+m+'" '+(m===state.finMonth?'selected':'')+'>'+m+'</option>').join("");
|
|
|
|
|
const toolQuarterSelect = qLabels2.map((l,i) => '<option value="'+i+'" '+(i===state.finQuarter?'selected':'')+'>'+l+'</option>').join("");
|
|
|
|
|
const toolDateSelect = state.finView==='monthly'?'<span class="text-xs text-slate-500 ml-2">月份:</span><select onchange="state.finMonth=this.value;renderFinance()" 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">'+toolMonthSelect+'</select>':state.finView==='quarterly'?'<span class="text-xs text-slate-500 ml-2">季度:</span><select onchange="state.finQuarter=parseInt(this.value);localStorage.setItem(\'opc-fin-quarter\',this.value);renderFinance()" 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">'+toolQuarterSelect+'</select>':'';
|
|
|
|
|
|
|
|
|
|
const finHeaderBase = `<span class="text-xs text-slate-500">视图:</span><select onchange="setFinView(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="overview" ${state.finView==='overview'?'selected':''}>总视图</option><option value="quarterly" ${state.finView==='quarterly'?'selected':''}>季度视图</option><option value="monthly" ${state.finView==='monthly'?'selected':''}>月度视图</option></select><span class="text-xs text-slate-500 ml-3">状态:</span><select onchange="state.finFilter=this.value;renderFinance()" 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="已签约" ${state.finFilter==='已签约'?'selected':''}>已签约 (${pfs.filter(x=>x.status==='已签约').length})</option><option value="待签约" ${state.finFilter==='待签约'?'selected':''}>待签约 (${pfs.filter(x=>x.status==='待签约').length})</option></select>${toolDateSelect}`;
|
|
|
|
|
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
|
|
|
|
|
|
|
|
|
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
|
|
|
|
|
<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>
|
|
|
|
|
${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "p-3")}
|
|
|
|
|
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
|
|
|
|
<div class="finance-tabs">
|
|
|
|
|
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
|
|
|
|
@@ -128,7 +138,7 @@ function renderFinance() {
|
|
|
|
|
<div class="grid grid-cols-3 gap-3">
|
|
|
|
|
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
|
|
|
|
|
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
|
|
|
|
|
<label class="block"><span class="fin-label">项目状态</span><select name="status" class="form-ctrl bg-white"><option>已签约</option><option>流程中</option><option>待签约</option></select></label>
|
|
|
|
|
<label class="block"><span class="fin-label">项目状态</span><select name="status" class="form-ctrl bg-white"><option>已签约</option><option>待签约</option></select></label>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="grid grid-cols-3 gap-3">
|
|
|
|
|
<label class="block"><span class="fin-label">商务负责人 <span class="text-red-500">*</span></span><input name="sales_person" required class="form-ctrl" placeholder="请输入商务负责人"></label>
|
|
|
|
|
@@ -213,88 +223,137 @@ function renderFinance() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div><div class="flex justify-end gap-3 pt-2 finance-form-actions"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
|
|
|
|
|
${state.finView === 'monthly' ? (() => {
|
|
|
|
|
const allPfs = pfs.filter(x => x.status === state.finFilter);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth()+1).padStart(2,"0");
|
|
|
|
|
if (!state.finMonth) state.finMonth = defaultMonth;
|
|
|
|
|
const selMonth = state.finMonth;
|
|
|
|
|
// 收集所有有数据的月份 + 当前月
|
|
|
|
|
const monthSet = new Set([defaultMonth]);
|
|
|
|
|
allPfs.forEach(pf => { let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {} bd.forEach(b => { if (b.month) monthSet.add(b.month); }); });
|
|
|
|
|
const sortedMonths = [...monthSet].sort().reverse();
|
|
|
|
|
const monthOpts = sortedMonths.map(m => `<option value="${m}" ${m === selMonth ? 'selected' : ''}>${m}</option>`).join("");
|
|
|
|
|
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
|
|
|
|
|
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
|
|
|
|
|
const rows = [];
|
|
|
|
|
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0, sumGross=0;
|
|
|
|
|
allPfs.forEach(pf => {
|
|
|
|
|
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
|
|
|
|
const b = bd.find(x => (x.month||"") === selMonth) || {};
|
|
|
|
|
const rev = Math.round(parseFloat(b.rev||0)||0);
|
|
|
|
|
const payment = Math.round(parseFloat(b.payment||0)||0);
|
|
|
|
|
const cost = Math.round(parseFloat(b.cost||0)||0);
|
|
|
|
|
const paid = Math.round(parseFloat(b.paid||0)||0);
|
|
|
|
|
const gross = Math.round(parseFloat(b.gross||0)||0);
|
|
|
|
|
if (!rev && !payment && !cost && !paid && !gross) return;
|
|
|
|
|
const payDiff = rev - payment;
|
|
|
|
|
const costDiff = cost - paid;
|
|
|
|
|
const cashflow = payment - paid;
|
|
|
|
|
sumRev+=rev; sumPay+=payment; sumCost+=cost; sumPaid+=paid; sumGross+=gross;
|
|
|
|
|
rows.push(`<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">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</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">${fmt(rev)}</td><td class="p-2 text-center align-middle text-xs">${rev&&pf.sign_amount?Math.round(rev/pf.sign_amount*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle text-xs">${rev&&payment?Math.round(payment/rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle text-xs">${cost&&paid?Math.round(paid/cost*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmt(gross)}</td><td class="p-2 text-center align-middle text-xs">${rev&&gross?Math.round(gross/rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
|
|
|
|
|
});
|
|
|
|
|
return card(`<div class="flex items-center gap-2 mb-3"><span class="text-xs text-slate-500">月份:</span><select onchange="state.finMonth=this.value;renderFinance()" 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></div><div class="grid grid-cols-6 gap-3 mb-3">${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].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><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 align-middle">项目名称</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-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle">执行率</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款率</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">付款率</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-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="13" class="p-6 text-center text-slate-400">该月份暂无数据</td></tr>'}</tbody></table></div>`, "p-4");
|
|
|
|
|
})() : state.finView === 'quarterly' ? (() => {
|
|
|
|
|
const allPfs = pfs.filter(x => x.status === state.finFilter);
|
|
|
|
|
const qRanges = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
|
|
|
|
const qLabels = ["Q1 (1-3月)", "Q2 (4-6月)", "Q3 (7-9月)", "Q4 (10-12月)"];
|
|
|
|
|
const now = new Date();
|
|
|
|
|
if (state.finQuarter === undefined) {
|
|
|
|
|
const saved = localStorage.getItem("opc-fin-quarter");
|
|
|
|
|
state.finQuarter = saved !== null ? parseInt(saved) : Math.floor(now.getMonth() / 3);
|
|
|
|
|
}
|
|
|
|
|
const selQ = state.finQuarter;
|
|
|
|
|
const qRange = qRanges[selQ];
|
|
|
|
|
const quarterOpts = qLabels.map((l, i) => `<option value="${i}" ${i === selQ ? 'selected' : ''}>${l}</option>`).join("");
|
|
|
|
|
const sumBudget = (pf, field) => {
|
|
|
|
|
let total = 0;
|
|
|
|
|
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;
|
|
|
|
|
${(() => {
|
|
|
|
|
const METRIC_CARDS = [
|
|
|
|
|
{ label: '签约项目总数', value: ctx => ctx.signCnt, color: 'text-slate-700' },
|
|
|
|
|
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
|
|
|
|
|
{ label: '已确收', value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
|
|
|
|
|
{ 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 => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
|
|
|
|
{ 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 => moneyWan(ctx.sumCost), color: 'text-rose-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 => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
|
|
|
|
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt };
|
|
|
|
|
const cards = METRIC_CARDS.map(c => {
|
|
|
|
|
const v = c.value(ctx);
|
|
|
|
|
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
|
|
|
|
|
});
|
|
|
|
|
const thead = '<thead><tr class="bg-slate-50 border-b border-slate-200">' +
|
|
|
|
|
'<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">签约金额</th>' +
|
|
|
|
|
'<th class="p-2 text-center font-semibold align-middle text-blue-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">毛利率</th>' +
|
|
|
|
|
'<th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th>' +
|
|
|
|
|
'<th class="p-2 text-center font-semibold align-middle">回款率</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">付款率</th>' +
|
|
|
|
|
'<th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th>' +
|
|
|
|
|
'</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>`;
|
|
|
|
|
|
|
|
|
|
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") +
|
|
|
|
|
card(`<div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
|
|
|
|
};
|
|
|
|
|
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
|
|
|
|
|
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
|
|
|
|
|
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
|
|
|
|
|
const rows = [];
|
|
|
|
|
allPfs.forEach(pf => {
|
|
|
|
|
const rev = sumBudget(pf, "rev");
|
|
|
|
|
const payment = sumBudget(pf, "payment");
|
|
|
|
|
const cost = sumBudget(pf, "cost");
|
|
|
|
|
const paid = sumBudget(pf, "paid");
|
|
|
|
|
const gross = sumBudget(pf, "gross");
|
|
|
|
|
const payDiff = rev - payment;
|
|
|
|
|
const costDiff = cost - paid;
|
|
|
|
|
|
|
|
|
|
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 cashflow = payment - paid;
|
|
|
|
|
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
|
|
|
|
|
rows.push(`<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">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</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">${fmt(rev)}</td><td class="p-2 text-center align-middle text-xs">${rev&&pf.sign_amount?Math.round(rev/pf.sign_amount*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle text-xs">${rev&&payment?Math.round(payment/rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle text-xs">${cost&&paid?Math.round(paid/cost*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmt(gross)}</td><td class="p-2 text-center align-middle text-xs">${rev&&gross?Math.round(gross/rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
|
|
|
|
|
});
|
|
|
|
|
return card(`<div class="flex items-center gap-2 mb-3"><span class="text-xs text-slate-500">季度:</span><select onchange="state.finQuarter=parseInt(this.value);localStorage.setItem('opc-fin-quarter',this.value);renderFinance()" 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></div><div class="grid grid-cols-6 gap-3 mb-3">${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].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><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 align-middle">项目名称</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-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle">执行率</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款率</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">付款率</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-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="13" class="p-6 text-center text-slate-400">该季度暂无数据</td></tr>'}</tbody></table></div>`, "p-4");
|
|
|
|
|
})() : (() => {
|
|
|
|
|
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 paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
|
|
|
|
|
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</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 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>`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (state.finView === 'monthly') {
|
|
|
|
|
const allPfs = pfs.filter(x => x.status === state.finFilter);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
|
|
|
|
|
if (!state.finMonth) state.finMonth = defaultMonth;
|
|
|
|
|
const selMonth = state.finMonth;
|
|
|
|
|
const rows = [];
|
|
|
|
|
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
|
|
|
|
|
allPfs.forEach(pf => {
|
|
|
|
|
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
|
|
|
|
|
const b = bd.find(x => (x.month || "") === selMonth) || {};
|
|
|
|
|
const rev = Math.round(parseFloat(b.rev || 0) || 0);
|
|
|
|
|
const payment = Math.round(parseFloat(b.payment || 0) || 0);
|
|
|
|
|
const cost = Math.round(parseFloat(b.cost || 0) || 0);
|
|
|
|
|
const paid = Math.round(parseFloat(b.paid || 0) || 0);
|
|
|
|
|
const gross = Math.round(parseFloat(b.gross || 0) || 0);
|
|
|
|
|
if (!rev && !payment && !cost && !paid && !gross) return;
|
|
|
|
|
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
|
|
|
|
|
rows.push(tdRow(pf, rev, payment, cost, paid, gross));
|
|
|
|
|
});
|
|
|
|
|
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;
|
|
|
|
|
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.finView === 'quarterly') {
|
|
|
|
|
const allPfs = pfs.filter(x => x.status === state.finFilter);
|
|
|
|
|
const qRanges = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
|
|
|
|
|
const now = new Date();
|
|
|
|
|
if (state.finQuarter === undefined) {
|
|
|
|
|
const saved = localStorage.getItem("opc-fin-quarter");
|
|
|
|
|
state.finQuarter = saved !== null ? parseInt(saved) : Math.floor(now.getMonth() / 3);
|
|
|
|
|
}
|
|
|
|
|
const selQ = state.finQuarter;
|
|
|
|
|
const qRange = qRanges[selQ];
|
|
|
|
|
const sumBudget = (pf, field) => {
|
|
|
|
|
let total = 0;
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
|
|
|
|
|
const rows = [];
|
|
|
|
|
allPfs.forEach(pf => {
|
|
|
|
|
const rev = sumBudget(pf, "rev");
|
|
|
|
|
const payment = sumBudget(pf, "payment");
|
|
|
|
|
const cost = sumBudget(pf, "cost");
|
|
|
|
|
const paid = sumBudget(pf, "paid");
|
|
|
|
|
const gross = sumBudget(pf, "gross");
|
|
|
|
|
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
|
|
|
|
|
rows.push(tdRow(pf, rev, payment, cost, paid, gross));
|
|
|
|
|
});
|
|
|
|
|
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;
|
|
|
|
|
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 总视图
|
|
|
|
|
const calcTotals = (pf) => {
|
|
|
|
|
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
|
|
|
|
|
let rev = 0, payment = 0, cost = 0, gross = 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; });
|
|
|
|
|
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) };
|
|
|
|
|
};
|
|
|
|
|
const allPfs = pfs.filter(x => x.status === state.finFilter);
|
|
|
|
|
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0, sumGross=0;
|
|
|
|
|
allPfs.forEach(pf => { const t = calcTotals(pf); sumRev+=t.rev; sumPay+=t.payment; sumCost+=t.cost; sumPaid+=t.paid; sumGross+=t.gross; });
|
|
|
|
|
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
|
|
|
|
|
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
|
|
|
|
|
return card(`<div class="grid grid-cols-6 gap-3 mb-3">${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].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><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 align-middle">项目名称</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-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle">执行率</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款率</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">付款率</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-slate-700">现金流</th></tr></thead><tbody>${allPfs.map(pf => { const t = calcTotals(pf); const payDiff = t.rev - t.payment; const costDiff = t.cost - t.paid; const cashflow = t.payment - t.paid; 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">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</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">${fmt(t.rev)}</td><td class="p-2 text-center align-middle text-xs">${t.rev&&pf.sign_amount?Math.round(t.rev/pf.sign_amount*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(t.payment)}</td><td class="p-2 text-center align-middle text-xs">${t.rev&&t.payment?Math.round(t.payment/t.rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(t.cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(t.paid)}</td><td class="p-2 text-center align-middle text-xs">${t.cost&&t.paid?Math.round(t.paid/t.cost*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmt(t.gross)}</td><td class="p-2 text-center align-middle text-xs">${t.rev&&t.gross?Math.round(t.gross/t.rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`; }).join("")}</tbody></table></div>`, "p-4");
|
|
|
|
|
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
|
|
|
|
|
const rows = [];
|
|
|
|
|
allPfs.forEach(pf => {
|
|
|
|
|
const t = calcTotals(pf);
|
|
|
|
|
sumRev += t.rev; sumPay += t.payment; sumCost += t.cost; sumPaid += t.paid; sumGross += t.gross;
|
|
|
|
|
rows.push(tdRow(pf, t.rev, t.payment, t.cost, t.paid, t.gross));
|
|
|
|
|
});
|
|
|
|
|
const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
|
|
|
|
|
const signCnt = allPfs.filter(x => x.status === "已签约").length;
|
|
|
|
|
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据');
|
|
|
|
|
})()}
|
|
|
|
|
</div>`;
|
|
|
|
|
if (window.lucide) window.lucide.createIcons();
|
|
|
|
|
|