|
|
|
|
@@ -287,15 +287,14 @@ function renderFinance() {
|
|
|
|
|
};
|
|
|
|
|
const SIGNED_COLS = [
|
|
|
|
|
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
|
|
|
|
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
|
|
|
|
|
{ key: 'profit', label: '项目利润' }, { key: 'payment', label: '已回款' }, { key: 'paid', label: '已付' },
|
|
|
|
|
{ key: 'cashflow', label: '现金流' }, { key: 'exe_rate', label: '执行率' }, { key: 'gross_rate', label: '毛利率' },
|
|
|
|
|
{ key: 'pay_rate', label: '回款率' }, { key: 'paid_rate', label: '付款率' },
|
|
|
|
|
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
|
|
|
|
{ key: 'payment', label: '回款' }, { key: 'cashflow', label: '现金流' }, { key: 'pay_rate', label: '回款率' },
|
|
|
|
|
{ key: 'cost', label: '成本' }, { key: 'paid', label: '已付' }, { key: 'paid_rate', label: '付款率' },
|
|
|
|
|
];
|
|
|
|
|
const UNSIGNED_COLS = [
|
|
|
|
|
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
|
|
|
|
{ key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
|
|
|
|
|
{ key: 'profit', label: '项目利润' },
|
|
|
|
|
,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
|
|
|
|
@@ -305,7 +304,7 @@ function renderFinance() {
|
|
|
|
|
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
|
|
|
|
|
});
|
|
|
|
|
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
|
|
|
|
const theadCols = isUnsigned ? 7 : 14;
|
|
|
|
|
const theadCols = isUnsigned ? 6 : 12;
|
|
|
|
|
const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
|
|
|
|
|
|
|
|
|
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
|
|
|
|
|
@@ -327,17 +326,11 @@ function renderFinance() {
|
|
|
|
|
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
|
|
|
|
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
|
|
|
|
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
|
|
|
|
const profit = gross;
|
|
|
|
|
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
|
|
|
|
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
|
|
|
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><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 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-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="p-2 text-center align-middle text-sm">${exe}</td><td class="p-2 text-center align-middle text-sm">${grossR}</td><td class="p-2 text-center align-middle text-sm">${payR}</td><td class="p-2 text-center align-middle text-sm">${paidR}</td></tr>`;
|
|
|
|
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><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 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-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center align-middle text-sm">${grossR}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="p-2 text-center align-middle text-sm">${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-sm">${paidR}</td></tr>`;
|
|
|
|
|
};
|
|
|
|
|
// 待签约简版行
|
|
|
|
|
const tdRowUnsigned = (pf, cost, gross) => {
|
|
|
|
|
const profit = gross;
|
|
|
|
|
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
|
|
|
|
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
|
|
|
|
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 font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
|
|
|
|
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 font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (state.finView === 'monthly') {
|
|
|
|
|
@@ -360,7 +353,7 @@ function renderFinance() {
|
|
|
|
|
if (!rev && !payment && !cost && !paid && !gross) return;
|
|
|
|
|
dataItems.push({
|
|
|
|
|
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
|
|
|
|
rev: rev, gross: gross, cost: cost, profit: gross, payment: payment, paid: paid,
|
|
|
|
|
rev: rev, gross: gross, cost: cost, payment: payment, paid: paid,
|
|
|
|
|
cashflow: payment - paid,
|
|
|
|
|
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
|
|
|
|
|
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
|
|
|
|
|
@@ -411,7 +404,7 @@ function renderFinance() {
|
|
|
|
|
if (!rev && !payment && !cost && !paid && !gross) return;
|
|
|
|
|
dataItems.push({
|
|
|
|
|
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
|
|
|
|
rev: rev, gross: gross, cost: cost, profit: gross, payment: payment, paid: paid,
|
|
|
|
|
rev: rev, gross: gross, cost: cost, payment: payment, paid: paid,
|
|
|
|
|
cashflow: payment - paid,
|
|
|
|
|
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
|
|
|
|
|
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
|
|
|
|
|
@@ -447,7 +440,7 @@ function renderFinance() {
|
|
|
|
|
var t = calcTotals(pf);
|
|
|
|
|
return {
|
|
|
|
|
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
|
|
|
|
rev: t.rev, gross: t.gross, cost: t.cost, profit: t.gross,
|
|
|
|
|
rev: t.rev, gross: t.gross, cost: t.cost,
|
|
|
|
|
payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid,
|
|
|
|
|
exe_rate: t.rev && pf.sign_amount ? Math.round(t.rev / pf.sign_amount * 100) : 0,
|
|
|
|
|
gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
|
|
|
|
|
@@ -943,26 +936,22 @@ function renderFinanceOverview() {
|
|
|
|
|
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
|
|
|
|
const fm = financeMonthly || [];
|
|
|
|
|
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
|
|
|
|
const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
|
|
|
|
|
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.profit_annual||0, m.payment_annual||0, m.paid_annual||0, m.cashflow_annual||0];
|
|
|
|
|
const fmFields = ['sign','revenue','gross','cost',null,'payment','paid',null];
|
|
|
|
|
const LABELS = ['合同金额','确收金额','确收毛利','成本','已付'];
|
|
|
|
|
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0];
|
|
|
|
|
const fmFields = ['sign','revenue','gross','cost','paid'];
|
|
|
|
|
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
|
|
|
|
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
|
|
|
|
|
thead += '</tr>';
|
|
|
|
|
var tbody = '';
|
|
|
|
|
for (var ri = 0; ri < 8; ri++) {
|
|
|
|
|
for (var ri = 0; ri < 5; ri++) {
|
|
|
|
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
|
|
|
|
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
|
|
|
|
for (var mi = 0; mi < 12; mi++) {
|
|
|
|
|
var val = 0;
|
|
|
|
|
if (fm[mi]) {
|
|
|
|
|
if (ri === 4) { val = (fm[mi].gross || 0) - (fm[mi].cost || 0); }
|
|
|
|
|
else if (ri === 7) { val = (fm[mi].payment || 0) - (fm[mi].paid || 0); }
|
|
|
|
|
else { var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0; }
|
|
|
|
|
var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0;
|
|
|
|
|
}
|
|
|
|
|
var cls = 'text-slate-700';
|
|
|
|
|
if (ri === 4) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
|
|
|
|
if (ri === 7) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
|
|
|
|
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
|
|
|
|
}
|
|
|
|
|
tbody += rowHtml + '</tr>';
|
|
|
|
|
@@ -970,11 +959,10 @@ function renderFinanceOverview() {
|
|
|
|
|
|
|
|
|
|
document.querySelector("#financeOverview").innerHTML = '<div class="grid gap-5">' +
|
|
|
|
|
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务月度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
|
|
|
|
'<div class="grid grid-cols-4 gap-5">' +
|
|
|
|
|
'<div class="grid grid-cols-3 gap-5">' +
|
|
|
|
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartSign"></canvas></div>', 'p-4') +
|
|
|
|
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartRev"></canvas></div>', 'p-4') +
|
|
|
|
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
|
|
|
|
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度项目利润</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartProfit"></canvas></div>', 'p-4') +
|
|
|
|
|
'</div></div>';
|
|
|
|
|
|
|
|
|
|
if (window.lucide) window.lucide.createIcons();
|
|
|
|
|
@@ -991,21 +979,19 @@ function renderFinanceQuarterlyOverview() {
|
|
|
|
|
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
|
|
|
|
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
|
|
|
|
const fm = financeMonthly || [];
|
|
|
|
|
const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
|
|
|
|
|
const LABELS = ['合同金额','确收金额','确收毛利','成本','已付'];
|
|
|
|
|
const qLbl = ['Q1','Q2','Q3','Q4'];
|
|
|
|
|
const qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
|
|
|
|
|
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.profit_annual||0, m.payment_annual||0, m.paid_annual||0, m.cashflow_annual||0];
|
|
|
|
|
const fmFields = ['sign','revenue','gross','cost',null,'payment','paid',null];
|
|
|
|
|
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
|
|
|
|
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0];
|
|
|
|
|
const fmFields = ['sign','revenue','gross','cost','paid'];
|
|
|
|
|
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
|
|
|
|
for (var qi = 0; qi < 4; qi++) {
|
|
|
|
|
for (var ri = 0; ri < 8; ri++) {
|
|
|
|
|
for (var ri = 0; ri < 5; ri++) {
|
|
|
|
|
var sum = 0;
|
|
|
|
|
for (var mi = 0; mi < 3; mi++) {
|
|
|
|
|
var idx = qRanges[qi][mi];
|
|
|
|
|
if (fm[idx]) {
|
|
|
|
|
if (ri === 4) { sum += (fm[idx].gross||0) - (fm[idx].cost||0); }
|
|
|
|
|
else if (ri === 7) { sum += (fm[idx].payment||0) - (fm[idx].paid||0); }
|
|
|
|
|
else { var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0; }
|
|
|
|
|
var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
qData[ri][qi] = sum;
|
|
|
|
|
@@ -1019,14 +1005,12 @@ function renderFinanceQuarterlyOverview() {
|
|
|
|
|
thead += '</tr>';
|
|
|
|
|
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
|
|
|
|
|
var tbody = '';
|
|
|
|
|
for (var ri = 0; ri < 8; ri++) {
|
|
|
|
|
for (var ri = 0; ri < 5; ri++) {
|
|
|
|
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
|
|
|
|
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
|
|
|
|
for (var qi = 0; qi < 4; qi++) {
|
|
|
|
|
var val = qData[ri][qi];
|
|
|
|
|
var cls = 'text-slate-700';
|
|
|
|
|
if (ri === 4) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
|
|
|
|
if (ri === 7) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
|
|
|
|
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
|
|
|
|
if (qi > 0) { rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
|
|
|
|
|
}
|
|
|
|
|
|