- 列顺序:年度累计→上季度累计→上月累计→本季度累计→季环比→本月累计→月环比 - 业务财务概览加回项目费用行(橙色,现金流之前) - 本月新增→本月累计,季度累计→本季度累计 - 现金流/项目利润/部门净利:正值绿负值红 - 部门利润→部门毛利
234 lines
15 KiB
JavaScript
234 lines
15 KiB
JavaScript
// home.js — 首页渲染 + 财务趋势图
|
||
|
||
function renderHome() {
|
||
const { summary, financeMonthly } = state.data;
|
||
const m = summary.metrics;
|
||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
||
const rows1 = [
|
||
["年度累计", moneyInt(m.signed_annual || m.signed_amount)],
|
||
["本季度累计", moneyInt(m.signed_q2 || 0)],
|
||
["本月累计", moneyInt(m.signed_month || 0)],
|
||
["上本季度累计", moneyInt(m.signed_prev_q || 0)],
|
||
["上月累计", moneyInt(m.signed_prev_month || 0)],
|
||
];
|
||
const rows2 = [
|
||
["年度累计", moneyInt(m.revenue_annual)],
|
||
["本季度累计", moneyInt(m.revenue_q2)],
|
||
["本月累计", moneyInt(m.monthly_revenue)],
|
||
["上本季度累计", moneyInt(m.revenue_prev_q || 0)],
|
||
["上月累计", moneyInt(m.revenue_prev_month || 0)],
|
||
];
|
||
const rows3 = [
|
||
["年度累计", moneyInt(m.gross_annual)],
|
||
["本季度累计", moneyInt(m.gross_q2)],
|
||
["本月累计", moneyInt(m.monthly_net_profit)],
|
||
["上本季度累计", moneyInt(m.gross_prev_q || 0)],
|
||
["上月累计", moneyInt(m.gross_prev_month || 0)],
|
||
];
|
||
const rows4 = [
|
||
["年度累计", moneyInt(m.payment_annual || 0)],
|
||
["本季度累计", moneyInt(m.payment_q2 || 0)],
|
||
["本月累计", moneyInt(m.payment_month || 0)],
|
||
["上本季度累计", moneyInt(m.payment_prev_q || 0)],
|
||
["上月累计", moneyInt(m.payment_prev_month || 0)],
|
||
];
|
||
const rows5 = [
|
||
["年度累计", moneyInt(m.cost_annual || 0)],
|
||
["本季度累计", moneyInt(m.cost_q2 || 0)],
|
||
["本月累计", moneyInt(m.cost_month || 0)],
|
||
["上本季度累计", moneyInt(m.cost_prev_q || 0)],
|
||
["上月累计", moneyInt(m.cost_prev_month || 0)],
|
||
];
|
||
const rows7 = [
|
||
["年度累计", moneyInt(m.paid_annual || 0)],
|
||
["本季度累计", moneyInt(m.paid_q2 || 0)],
|
||
["本月累计", moneyInt(m.paid_month || 0)],
|
||
["上本季度累计", moneyInt(m.paid_prev_q || 0)],
|
||
["上月累计", moneyInt(m.paid_prev_month || 0)],
|
||
];
|
||
const rowsProjExpense = [
|
||
["年度累计", moneyInt(m.proj_expense_annual || 0)],
|
||
["本季度累计", moneyInt(m.proj_expense_q2 || 0)],
|
||
["本月累计", moneyInt(m.proj_expense_month || 0)],
|
||
["上本季度累计", moneyInt(m.proj_expense_prev_q || 0)],
|
||
["上月累计", moneyInt(m.proj_expense_prev_month || 0)],
|
||
];
|
||
const rows8 = [
|
||
["年度累计", moneyInt(m.cashflow_annual || 0)],
|
||
["本季度累计", moneyInt(m.cashflow_q2 || 0)],
|
||
["本月累计", moneyInt(m.cashflow_month || 0)],
|
||
["上本季度累计", moneyInt(m.cashflow_prev_q || 0)],
|
||
["上月累计", moneyInt(m.cashflow_prev_month || 0)],
|
||
];
|
||
const rows9 = [
|
||
["年度累计", moneyInt(m.profit_annual || 0)],
|
||
["本季度累计", moneyInt(m.profit_q2 || 0)],
|
||
["本月累计", moneyInt(m.profit_month || 0)],
|
||
["上本季度累计", moneyInt(m.profit_prev_q || 0)],
|
||
["上月累计", moneyInt(m.profit_prev_month || 0)],
|
||
];
|
||
const qoq = (cur, prev) => {
|
||
if (!prev) return '<span class="text-slate-300">—</span>';
|
||
const pct = Math.round((cur - prev) / prev * 100);
|
||
const cls = pct >= 0 ? 'text-green-600' : 'text-red-600';
|
||
const sign = pct >= 0 ? '+' : '';
|
||
return '<span class="' + cls + '">' + sign + pct + '%</span>';
|
||
};
|
||
const COLORS = ['text-slate-700','text-blue-600','text-green-600','text-amber-600','text-rose-600','text-purple-600','text-orange-600','text-cyan-600','text-indigo-600'];
|
||
const LABELS = ['合同金额','确收金额','确收毛利','回款金额','应付金额','已付','项目费用','现金流','项目利润'];
|
||
const Q_FIELDS = [m.signed_q2||0, m.revenue_q2, m.gross_q2, m.payment_q2||0, m.cost_q2||0, m.paid_q2||0, m.proj_expense_q2||0, m.cashflow_q2||0, m.profit_q2||0];
|
||
const Q_PREV = [m.signed_prev_q||0, m.revenue_prev_q||0, m.gross_prev_q||0, m.payment_prev_q||0, m.cost_prev_q||0, m.paid_prev_q||0, m.proj_expense_prev_q||0, m.cashflow_prev_q||0, m.profit_prev_q||0];
|
||
const M_FIELDS = [m.signed_month||0, m.monthly_revenue, m.monthly_net_profit, m.payment_month||0, m.cost_month||0, m.paid_month||0, m.proj_expense_month||0, m.cashflow_month||0, m.profit_month||0];
|
||
const M_PREV = [m.signed_prev_month||0, m.revenue_prev_month||0, m.gross_prev_month||0, m.payment_prev_month||0, m.cost_prev_month||0, m.paid_prev_month||0, m.proj_expense_prev_month||0, m.cashflow_prev_month||0, m.profit_prev_month||0];
|
||
const ROWS = [rows1, rows2, rows3, rows4, rows5, rows7, rowsProjExpense, rows8, rows9];
|
||
// 现金流和项目利润的原始值数组,用于正负着色
|
||
const CF_RAW = [m.cashflow_annual||0, m.cashflow_q2||0, m.cashflow_month||0, m.cashflow_prev_q||0, m.cashflow_prev_month||0];
|
||
const PF_RAW = [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0];
|
||
var valCls = function(ri) { return (ri === 7 || ri === 8) ? '' : 'text-slate-800'; };
|
||
var tdVal = function(ri, col, val) {
|
||
if (ri === 7) return '<td class="py-2 text-right font-semibold ' + (CF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
|
||
if (ri === 8) return '<td class="py-2 text-right font-semibold ' + (PF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
|
||
return '<td class="py-2 text-right font-semibold text-slate-800">' + val + '</td>';
|
||
};
|
||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500">指标</th><th class="py-2 text-right font-semibold text-slate-700">年度累计</th><th class="py-2 text-right font-semibold text-slate-700">上本季度累计</th><th class="py-2 text-right font-semibold text-slate-700">上月累计</th><th class="py-2 text-right font-semibold text-slate-700">本季度累计</th><th class="py-2 text-right font-semibold text-slate-400">季环比</th><th class="py-2 text-right font-semibold text-slate-700">本月累计</th><th class="py-2 text-right font-semibold text-slate-400">月环比</th></tr>';
|
||
var tbody = '';
|
||
for (var ri = 0; ri < 9; ri++) {
|
||
var vals = ROWS[ri];
|
||
tbody += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + LABELS[ri] + '</td>' +
|
||
tdVal(ri, 0, vals[0][1]) +
|
||
tdVal(ri, 3, vals[3][1]) +
|
||
tdVal(ri, 4, vals[4][1]) +
|
||
tdVal(ri, 1, vals[1][1]) +
|
||
'<td class="py-2 text-right">' + qoq(Q_FIELDS[ri], Q_PREV[ri]) + '</td>' +
|
||
tdVal(ri, 2, vals[2][1]) +
|
||
'<td class="py-2 text-right">' + qoq(M_FIELDS[ri], M_PREV[ri]) + '</td></tr>';
|
||
}
|
||
document.querySelector("#home").innerHTML = `
|
||
<div class="grid gap-5">
|
||
${state.tenant === "总工作台" ? "" : `<div class="grid grid-cols-4 gap-3">
|
||
${[
|
||
["项目管理", m.total_projects, "finance"],
|
||
["重点工作与台账", m.total_proposals, "projects"],
|
||
["业务方案", m.total_products, "proposals"],
|
||
["产品迭代", m.upcoming_products, "products"],
|
||
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
|
||
</div>`}
|
||
${card(`<h3 class="text-sm font-bold text-slate-700 mb-3">部门经营情况</h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500">指标</th><th class="py-2 text-right font-semibold text-slate-700">年度累计</th><th class="py-2 text-right font-semibold text-slate-700">上本季度累计</th><th class="py-2 text-right font-semibold text-slate-700">上月累计</th><th class="py-2 text-right font-semibold text-slate-700">本季度累计</th><th class="py-2 text-right font-semibold text-slate-400">季环比</th><th class="py-2 text-right font-semibold text-slate-700">本月累计</th><th class="py-2 text-right font-semibold text-slate-400">月环比</th></tr></thead><tbody>
|
||
${(() => {
|
||
var d = [
|
||
['部门毛利', [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0],
|
||
[m.profit_q2||0,m.profit_prev_q||0], [m.profit_month||0,m.profit_prev_month||0]],
|
||
['部门费用', [m.expense_annual||0, m.expense_q2||0, (m.expense_month||0), m.expense_prev_q||0, m.expense_prev_month||0],
|
||
[m.expense_q2||0,m.expense_prev_q||0], [(m.expense_month||0),m.expense_prev_month||0]],
|
||
['部门净利', [
|
||
(m.profit_annual||0)-(m.expense_annual||0), (m.profit_q2||0)-(m.expense_q2||0), (m.profit_month||0)-(m.expense_month||0),
|
||
(m.profit_prev_q||0)-(m.expense_prev_q||0), (m.profit_prev_month||0)-(m.expense_prev_month||0)
|
||
], [
|
||
(m.profit_q2||0)-(m.expense_q2||0), (m.profit_prev_q||0)-(m.expense_prev_q||0)
|
||
], [
|
||
(m.profit_month||0)-(m.expense_month||0), (m.profit_prev_month||0)-(m.expense_prev_month||0)
|
||
]]
|
||
];
|
||
var h = '';
|
||
var netCls = function(di, raw) { return di === 2 ? (raw >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-800'; };
|
||
for (var di = 0; di < 3; di++) {
|
||
var r = d[di];
|
||
h += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + r[0] + '</td>' +
|
||
'<td class="py-2 text-right font-semibold ' + netCls(di, r[1][0]) + '">' + moneyInt(r[1][0]) + '</td>' +
|
||
'<td class="py-2 text-right font-semibold ' + netCls(di, r[1][3]) + '">' + moneyInt(r[1][3]) + '</td>' +
|
||
'<td class="py-2 text-right font-semibold ' + netCls(di, r[1][4]) + '">' + moneyInt(r[1][4]) + '</td>' +
|
||
'<td class="py-2 text-right font-semibold ' + netCls(di, r[1][1]) + '">' + moneyInt(r[1][1]) + '</td>' +
|
||
'<td class="py-2 text-right">' + qoq(r[2][0], r[2][1]) + '</td>' +
|
||
'<td class="py-2 text-right font-semibold ' + netCls(di, r[1][2]) + '">' + moneyInt(r[1][2]) + '</td>' +
|
||
'<td class="py-2 text-right">' + qoq(r[3][0], r[3][1]) + '</td></tr>';
|
||
}
|
||
return h;
|
||
})()}
|
||
</tbody></table></div>`, "p-4")}
|
||
${card(`<h3 class="text-sm font-bold text-slate-700 mb-3">业务(项目)财务概览</h3><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">
|
||
${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="chartSign"></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="chartRev"></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="chartCash"></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="chartProfit"></canvas></div>`, "p-4")}
|
||
</div>
|
||
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")}
|
||
</div>
|
||
`;
|
||
renderCharts(financeMonthly);
|
||
}
|
||
|
||
function chartOptions(yCallback) {
|
||
return {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } },
|
||
scales: {
|
||
x: { ticks: { font: { size: 10 } }, grid: { display: false } },
|
||
y: { ticks: { font: { size: 11 }, callback: yCallback } },
|
||
},
|
||
};
|
||
}
|
||
|
||
const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v;
|
||
const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月");
|
||
|
||
function renderCharts(data) {
|
||
const labels = monthLabels(data);
|
||
const baseOpts = chartOptions(moneyTick);
|
||
|
||
// 图1:月度签约
|
||
const c1 = document.querySelector("#chartSign");
|
||
if (c1 && window.Chart) {
|
||
if (state.chart) state.chart.destroy();
|
||
state.chart = new Chart(c1, {
|
||
type: "line",
|
||
data: { labels, datasets: [
|
||
{ label: "签约金额", data: data.map((x) => x.sign || 0), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 },
|
||
]},
|
||
options: baseOpts,
|
||
});
|
||
}
|
||
|
||
// 图2:月度确收与毛利
|
||
const c2 = document.querySelector("#chartRev");
|
||
if (c2 && window.Chart) {
|
||
if (state.chart2) state.chart2.destroy();
|
||
state.chart2 = new Chart(c2, {
|
||
type: "line",
|
||
data: { labels, datasets: [
|
||
{ label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 },
|
||
{ label: "毛利", data: data.map((x) => x.gross || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 },
|
||
]},
|
||
options: baseOpts,
|
||
});
|
||
}
|
||
|
||
// 图3:月度回款与已付
|
||
const c3 = document.querySelector("#chartCash");
|
||
if (c3 && window.Chart) {
|
||
if (state.chart3) state.chart3.destroy();
|
||
state.chart3 = new Chart(c3, {
|
||
type: "line",
|
||
data: { labels, datasets: [
|
||
{ label: "回款", data: data.map((x) => x.payment || 0), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 },
|
||
{ label: "已付", data: data.map((x) => x.cost || 0), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 },
|
||
]},
|
||
options: baseOpts,
|
||
});
|
||
}
|
||
|
||
// 图4:月度项目利润
|
||
const c4 = document.querySelector("#chartProfit");
|
||
if (c4 && window.Chart) {
|
||
if (state.chart4) state.chart4.destroy();
|
||
state.chart4 = new Chart(c4, {
|
||
type: "line",
|
||
data: { labels, datasets: [
|
||
{ label: "利润", data: data.map((x) => (x.gross || 0)), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 },
|
||
]},
|
||
options: baseOpts,
|
||
});
|
||
}
|
||
}
|