Files
mac 340f8bd88c feat: 首页财务概览重构 — 5卡片合并为表格 + 费用列 + 环比列
- 5张独立财务卡片合并为一张表格(行=时间维度,列=6项指标)
- 新增费用列(数据源expense_records,单/多租户路径均支持)
- 每列增加环比列(季度累计行=季度环比,本月新增行=月度环比)
- 环比:正值绿+xx%,负值红-xx%
- 后端新增expense_annual/q2/month/prev_q/prev_month指标
2026-07-06 18:20:34 +08:00

166 lines
8.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 rows6 = [
["年度累计", moneyInt(m.expense_annual || 0)],
["季度累计", moneyInt(m.expense_q2 || 0)],
["本月新增", moneyInt(m.expense_month || 0)],
["上季度累计", moneyInt(m.expense_prev_q || 0)],
["上月累计", moneyInt(m.expense_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-orange-600'];
const LABELS = ['合同金额','确收金额','确收毛利','回款金额','应付金额','费用'];
const ALL_ROWS = [rows1, rows2, rows3, rows4, rows5, rows6];
// 季度环比Q 值 vs prev_Q 值月度环比month 值 vs prev_month 值
const Q_FIELDS = [m.signed_q2||0, m.revenue_q2, m.gross_q2, m.payment_q2||0, m.cost_q2||0, m.expense_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.expense_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.expense_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.expense_prev_month||0];
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500"></th>';
for (var ci = 0; ci < 6; ci++) {
thead += '<th class="py-2 text-right font-semibold ' + COLORS[ci] + '">' + LABELS[ci] + '</th><th class="py-2 text-right font-semibold text-slate-400" style="font-size:11px">环比</th>';
}
thead += '</tr>';
var tbody = '';
for (var ri = 0; ri < 5; ri++) {
var rowLabel = rows1[ri][0];
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-slate-500">' + rowLabel + '</td>';
for (var ci = 0; ci < 6; ci++) {
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800">' + ALL_ROWS[ci][ri][1] + '</td>';
var momHtml = '';
if (ri === 1) momHtml = qoq(Q_FIELDS[ci], Q_PREV[ci]); // 季度累计行:季度环比
else if (ri === 2) momHtml = qoq(M_FIELDS[ci], M_PREV[ci]); // 本月新增行:月度环比
else momHtml = '<span class="text-slate-300">—</span>';
rowHtml += '<td class="py-2 text-right" style="font-size:12px">' + momHtml + '</td>';
}
rowHtml += '</tr>';
tbody += rowHtml;
}
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>${thead}</thead><tbody>${tbody}</tbody></table></div>`, "p-4")}
<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="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")}
</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: "bar",
data: { labels, datasets: [
{ label: "回款", data: data.map((x) => x.payment || 0), backgroundColor: "#d97706", borderRadius: 4 },
{ label: "费用", data: data.map((x) => x.cost || 0), backgroundColor: "#ef4444", borderRadius: 4 },
]},
options: baseOpts,
});
}
}