Files
ewoooc/web/static/js/page-sales-analysis.js
ogt 911393190d
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(analytics): enforce trustworthy chart rendering
2026-07-16 15:55:22 +08:00

705 lines
28 KiB
JavaScript
Raw 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.
/* =========================================================
page-sales-analysis.js
業績分析頁邏輯v3
依賴jQuery / Bootstrap5 / Chart.js 3.9 / chartjs-chart-treemap / DataTables / Flatpickr
analysis-chart-theme.js ← 全站暖色 palette + Chart.defaults
========================================================= */
(function () {
'use strict';
// ─── Token helpers ──────────────────────────────────────
const css = (name, fallback) => {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
};
const PALETTE = (window.EwoooCChart && window.EwoooCChart.palette) || [
css('--momo-page-accent', '#C77B4E'),
css('--momo-tag-olive', '#7A8C3F'),
css('--momo-tag-mahogany', '#9B4D3F'),
css('--momo-tag-honey', '#C39A3D'),
css('--momo-tag-walnut', '#7A5C44'),
css('--momo-tag-rust', '#B85C3A'),
css('--momo-tag-caramel', '#D49464')
];
const INK = css('--momo-text-strong', '#2A2520');
const MUTED = css('--momo-text-muted', '#6F635A');
const BORDER = css('--momo-border-subtle', '#E2D7C9');
const SURFACE = css('--momo-surface', '#FAF6EE');
const ACCENT = PALETTE[0];
// mix helper browser already supports color-mix; fallback to manual rgba
const mix = (c, pct) => `color-mix(in srgb, ${c} ${pct}%, transparent)`;
const buildPeriodApiUrl = (path, overrides = {}) => {
const url = new URL(path, window.location.origin);
new URLSearchParams(window.location.search).forEach((value, key) => {
url.searchParams.append(key, value);
});
Object.entries(overrides).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return url.toString();
};
// ─── Loading overlay ────────────────────────────────────
const overlay = document.getElementById('loadingOverlay');
const showLoading = (text) => {
if (!overlay) return;
overlay.style.display = 'flex';
const txt = document.getElementById('loadingText');
if (txt && text) txt.innerHTML = `<i class="fas fa-chart-bar me-2"></i>${text}`;
};
const hideLoading = () => { if (overlay) overlay.style.display = 'none'; };
// ─── Filter helpers ─────────────────────────────────────
window.setFilter = function (key, value) {
showLoading('正在更新篩選...');
const url = new URL(window.location.href);
if (value === 'all' || value === '' || value == null) url.searchParams.delete(key);
else url.searchParams.set(key, value);
window.location.href = url.toString();
};
window.handleDataRangeChange = function (sel) {
if (!sel.value) return;
showLoading('正在載入資料...');
const form = sel.closest('form');
if (form) form.submit();
};
window.clearDateRange = function () {
showLoading('正在清除日期...');
const url = new URL(window.location.href);
url.searchParams.delete('start_date');
url.searchParams.delete('end_date');
window.location.href = url.toString();
};
window.filterDropdown = function (input) {
const term = input.value.toLowerCase();
const items = input.closest('.dropdown-menu').querySelectorAll('.dropdown-item');
items.forEach((it) => {
const txt = it.textContent.toLowerCase();
it.style.display = txt.includes(term) ? '' : 'none';
});
};
window.showTopDetail = function (kind, metric) {
const url = new URL('/top_detail', window.location.origin);
url.searchParams.set('kind', kind);
url.searchParams.set('metric', metric);
// forward existing filters
new URLSearchParams(window.location.search).forEach((v, k) => {
if (!url.searchParams.has(k)) url.searchParams.set(k, v);
});
window.open(url.toString(), '_blank');
};
window.exportMarketingExcel = function (campaign) {
const url = new URL('/api/export/excel/marketing', window.location.origin);
url.searchParams.set('campaign', campaign || 'all');
new URLSearchParams(window.location.search).forEach((v, k) => {
if (!url.searchParams.has(k)) url.searchParams.set(k, v);
});
window.location.href = url.toString();
};
// ─── Flatpickr (zh-tw) ─────────────────────────────────
const initFlatpickr = () => {
if (typeof flatpickr === 'undefined') return;
const opts = {
dateFormat: 'Y-m-d',
locale: (flatpickr.l10ns && flatpickr.l10ns.zh_tw) || 'default',
allowInput: false,
static: true,
};
const start = document.getElementById('start_date');
const end = document.getElementById('end_date');
if (start) flatpickr(start, { ...opts, onChange: () => end && end.dispatchEvent(new Event('focus')) });
if (end) flatpickr(end, opts);
};
// ─── Read embedded data ────────────────────────────────
const readData = () => {
const node = document.getElementById('sales-data');
if (!node) return null;
try { return JSON.parse(node.textContent); }
catch (e) { console.warn('[sales-analysis] data parse failed:', e); return null; }
};
// ─── Chart factory helpers ─────────────────────────────
const baseGrid = { color: mix(INK, 8), drawBorder: false };
const baseTicks = { color: MUTED, font: { family: getComputedStyle(document.documentElement).fontFamily } };
const hasDrawableValues = (values) => Array.isArray(values) && values.some((value) => {
if (value && typeof value === 'object') {
return Object.values(value).some(item => Number.isFinite(Number(item)) && Math.abs(Number(item)) > 1e-9);
}
return Number.isFinite(Number(value)) && Math.abs(Number(value)) > 1e-9;
});
const chartValues = (series) => {
if (!series || typeof series !== 'object') return [];
if (Array.isArray(series.values) && series.values.length) return series.values;
return Array.isArray(series.chart_values) ? series.chart_values : [];
};
const renderChartEmpty = (canvas, message) => {
if (!canvas) return null;
const shell = canvas.closest('.sa-chart-shell');
if (!shell) return null;
shell.classList.add('chart-empty-active');
canvas.setAttribute('aria-hidden', 'true');
let empty = shell.querySelector('.sa-chart-empty');
if (!empty) {
empty = document.createElement('div');
empty.className = 'sa-chart-empty';
const title = document.createElement('strong');
title.textContent = '尚無可繪製資料';
const detail = document.createElement('span');
empty.append(title, detail);
shell.appendChild(empty);
}
empty.querySelector('span').textContent = message;
return null;
};
const prepareChartCanvas = (canvas) => {
if (!canvas) return;
const shell = canvas.closest('.sa-chart-shell');
if (shell) {
shell.classList.remove('chart-empty-active');
shell.querySelector('.sa-chart-empty')?.remove();
}
canvas.removeAttribute('aria-hidden');
};
const horizontalBar = (canvas, labels, values, label, fmt) => {
if (!canvas) return null;
if (!Array.isArray(labels) || !labels.length || !hasDrawableValues(values)) {
return renderChartEmpty(canvas, '所選期間沒有可比較的排行資料。');
}
prepareChartCanvas(canvas);
return new Chart(canvas, {
type: 'bar',
data: {
labels, datasets: [{
label, data: values,
backgroundColor: labels.map((_, i) => PALETTE[i % PALETTE.length]),
borderRadius: 6, borderSkipped: false,
}]
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: (c) => fmt ? fmt(c.parsed.x) : c.parsed.x.toLocaleString() }
}
},
scales: {
x: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() } },
y: {
grid: { display: false },
ticks: {
...baseTicks,
callback(value) {
const text = String(this.getLabelForValue(value) || '');
return text.length > 28 ? `${text.slice(0, 28)}...` : text;
}
}
}
}
}
});
};
const doughnut = (canvas, labels, values) => {
if (!canvas) return null;
if (!Array.isArray(labels) || !labels.length || !hasDrawableValues(values)) {
return renderChartEmpty(canvas, '所選期間沒有可計算的類別占比。');
}
prepareChartCanvas(canvas);
return new Chart(canvas, {
type: 'doughnut',
data: {
labels, datasets: [{
data: values,
backgroundColor: labels.map((_, i) => PALETTE[i % PALETTE.length]),
borderColor: SURFACE, borderWidth: 2,
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '55%',
plugins: {
legend: { position: 'right', labels: { color: INK, font: { size: 11 }, boxWidth: 12 } },
tooltip: {
callbacks: {
label: (c) => {
const total = c.dataset.data.reduce((a, b) => a + b, 0);
const pct = total ? (c.parsed / total * 100).toFixed(1) : 0;
return `${c.label}: ${c.parsed.toLocaleString()} (${pct}%)`;
}
}
}
}
}
});
};
const lineTrend = (canvas, labels, values, label, fmt) => {
if (!canvas) return null;
if (!Array.isArray(labels) || !labels.length || !hasDrawableValues(values)) {
return renderChartEmpty(canvas, '所選期間沒有可繪製的趨勢資料。');
}
prepareChartCanvas(canvas);
const singlePoint = labels.length === 1;
return new Chart(canvas, {
type: 'line',
data: {
labels, datasets: [{
label, data: values,
borderColor: ACCENT,
backgroundColor: mix(ACCENT, 14),
tension: 0.34, fill: true, pointBackgroundColor: ACCENT,
pointRadius: singlePoint ? 6 : 3, pointHoverRadius: 6, borderWidth: 2.5
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (c) => fmt ? fmt(c.parsed.y) : c.parsed.y.toLocaleString() } }
},
scales: {
x: { grid: { display: false }, ticks: baseTicks, offset: singlePoint },
y: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() }, beginAtZero: true }
}
}
});
};
const verticalBar = (canvas, labels, values, label, fmt) => {
if (!canvas) return null;
if (!Array.isArray(labels) || !labels.length || !hasDrawableValues(values)) {
return renderChartEmpty(canvas, '所選期間沒有可繪製的分布資料。');
}
prepareChartCanvas(canvas);
return new Chart(canvas, {
type: 'bar',
data: {
labels, datasets: [{
label, data: values,
backgroundColor: labels.map((_, i) => PALETTE[i % PALETTE.length]),
borderRadius: 5, borderSkipped: false
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => fmt ? fmt(c.parsed.y) : c.parsed.y.toLocaleString() } } },
scales: {
x: { grid: { display: false }, ticks: baseTicks },
y: { beginAtZero: true, grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() } }
}
}
});
};
const fmtMoney = (v) => '$' + Number(v).toLocaleString();
const fmtNum = (v) => Number(v).toLocaleString();
// ─── BCG quadrant plugin (cross-hair lines) ────────────
const bcgQuadrants = (xMid, yMid, xMax, yMax) => ({
id: 'bcgQuadrants',
beforeDraw(chart) {
const { ctx, chartArea, scales } = chart;
if (!chartArea) return;
const x = scales.x.getPixelForValue(xMid);
const y = scales.y.getPixelForValue(yMid);
ctx.save();
ctx.strokeStyle = mix(MUTED, 30);
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, chartArea.top); ctx.lineTo(x, chartArea.bottom);
ctx.moveTo(chartArea.left, y); ctx.lineTo(chartArea.right, y);
ctx.stroke();
// quadrant labels
ctx.fillStyle = mix(INK, 50);
ctx.font = 'bold 11px system-ui';
ctx.fillText('★ 明星 (Star)', chartArea.left + 12, chartArea.top + 18);
ctx.fillText('? 問題兒童 (Question)', x + 12, chartArea.top + 18);
ctx.fillText('🐮 金牛 (Cash Cow)', chartArea.left + 12, chartArea.bottom - 8);
ctx.fillText('🐶 瘦狗 (Dog)', x + 12, chartArea.bottom - 8);
ctx.restore();
}
});
// ─── Build all charts ──────────────────────────────────
const buildCharts = (data) => {
if (!data) return;
// 1. Top 20 horizontal bar
if (data.barData) {
horizontalBar(
document.getElementById('barChart'),
data.barData.labels, chartValues(data.barData),
data.barData.label || '銷售',
data.barData.is_money ? fmtMoney : fmtNum
);
}
// 2. Category doughnut
if (data.categoryData) {
doughnut(document.getElementById('categoryChart'),
data.categoryData.labels, chartValues(data.categoryData));
}
// 3. Treemap
const treemapEl = document.getElementById('treemapChart');
if (treemapEl && data.treemapData && data.treemapData.length) {
prepareChartCanvas(treemapEl);
new Chart(treemapEl, {
type: 'treemap',
data: {
datasets: [{
tree: data.treemapData,
key: 'value',
groups: ['category', 'name'],
backgroundColor: (ctx) => {
if (ctx.type !== 'data') return 'transparent';
const idx = ctx.dataIndex || 0;
return PALETTE[idx % PALETTE.length];
},
borderColor: SURFACE, borderWidth: 1.5, spacing: 2,
labels: {
display: true, color: '#fff8ef',
font: { size: 11, weight: '600' },
formatter: (c) => c.raw && c.raw._data ? c.raw._data.name : ''
}
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (c) => c.raw && c.raw._data ? `${c.raw._data.name}: ${fmtMoney(c.raw.v)}` : '' } }
}
}
});
} else {
renderChartEmpty(treemapEl, '所選期間沒有可建立板塊分布的商品業績。');
}
// 4. Price distribution (vertical bar)
if (data.priceDistData) {
verticalBar(document.getElementById('priceDistChart'),
data.priceDistData.labels, chartValues(data.priceDistData),
'業績', fmtMoney);
}
// 5. Scatter
const scEl = document.getElementById('scatterChart');
if (scEl && data.scatterData && data.scatterData.length) {
prepareChartCanvas(scEl);
new Chart(scEl, {
type: 'scatter',
data: {
datasets: [{
label: '商品',
data: data.scatterData,
backgroundColor: mix(PALETTE[0], 65),
borderColor: PALETTE[0], borderWidth: 1, pointRadius: 4
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (c) => `${c.raw.name || ''} — 價:${fmtMoney(c.parsed.x)} 量:${fmtNum(c.parsed.y)}` } }
},
scales: {
x: { title: { display: true, text: '單價 ($)', color: MUTED }, grid: baseGrid, ticks: baseTicks },
y: { title: { display: true, text: '銷量', color: MUTED }, grid: baseGrid, ticks: baseTicks }
}
}
});
} else {
renderChartEmpty(scEl, '所選期間沒有同時具備價格與銷量的商品資料。');
}
// 6. BCG matrix
const bcgEl = document.getElementById('bcgChart');
if (bcgEl && data.bcgData && data.bcgData.points && data.bcgData.points.length) {
prepareChartCanvas(bcgEl);
const pts = data.bcgData.points;
const xMid = data.bcgData.x_median || 0;
const yMid = data.bcgData.y_median || 0;
new Chart(bcgEl, {
type: 'scatter',
data: { datasets: [{
label: '商品',
data: pts,
backgroundColor: pts.map(p => {
if (p.x >= xMid && p.y >= yMid) return mix(PALETTE[1], 70); // Star
if (p.x < xMid && p.y >= yMid) return mix(PALETTE[3], 70); // Question
if (p.x >= xMid && p.y < yMid) return mix(PALETTE[0], 70); // Cash cow
return mix(MUTED, 40); // Dog
}),
borderColor: INK, borderWidth: 0.5, pointRadius: 5
}]},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (c) => `${c.raw.name || ''} — 銷量:${fmtNum(c.parsed.x)} 毛利率:${c.parsed.y.toFixed(1)}%` } }
},
scales: {
x: { title: { display: true, text: '銷量 (Qty)', color: MUTED }, grid: baseGrid, ticks: baseTicks },
y: { title: { display: true, text: '毛利率 (%)', color: MUTED }, grid: baseGrid, ticks: baseTicks }
}
},
plugins: [bcgQuadrants(xMid, yMid)]
});
} else {
renderChartEmpty(bcgEl, '所選期間缺少可計算毛利率與銷量的商品資料。');
}
// 7. Seasonality heatmap (matrix-style via grouped bars stacked fallback)
const seasEl = document.getElementById('seasonalityChart');
const seasonalityMatrix = data.seasonalityData && data.seasonalityData.matrix;
if (seasEl && Array.isArray(seasonalityMatrix) && seasonalityMatrix.some(hasDrawableValues)) {
prepareChartCanvas(seasEl);
const cats = data.seasonalityData.categories;
const months = data.seasonalityData.months;
// build datasets: one per category
const datasets = cats.map((cat, i) => ({
label: cat,
data: data.seasonalityData.matrix[i] || [],
backgroundColor: PALETTE[i % PALETTE.length],
borderRadius: 3
}));
new Chart(seasEl, {
type: 'bar',
data: { labels: months, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'right', labels: { color: INK, font: { size: 10 }, boxWidth: 10 } } },
scales: {
x: { stacked: true, grid: { display: false }, ticks: baseTicks },
y: { stacked: true, grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmtMoney(v) } }
}
}
});
} else {
renderChartEmpty(seasEl, '至少需要兩個月份的分類業績才能判讀淡旺季。');
}
// 8. Marketing — discount + coupon
const discountCanvas = document.getElementById('mktDiscountChart');
const couponCanvas = document.getElementById('mktCouponChart');
if (data.marketingData) {
if (data.marketingData.discount) {
horizontalBar(discountCanvas,
data.marketingData.discount.labels, data.marketingData.discount.values,
'折扣業績', fmtMoney);
} else {
renderChartEmpty(discountCanvas, '所選期間沒有折扣活動業績。');
}
if (data.marketingData.coupon) {
horizontalBar(couponCanvas,
data.marketingData.coupon.labels, data.marketingData.coupon.values,
'折價券業績', fmtMoney);
} else {
renderChartEmpty(couponCanvas, '所選期間沒有折價券活動業績。');
}
} else {
renderChartEmpty(discountCanvas, '所選期間沒有折扣活動業績。');
renderChartEmpty(couponCanvas, '所選期間沒有折價券活動業績。');
}
// 9. Time-dimension trends
if (data.monthlyData) {
lineTrend(document.getElementById('monthlyChart'),
data.monthlyData.labels, chartValues(data.monthlyData), '月業績', fmtMoney);
}
if (data.weeklyData) {
lineTrend(document.getElementById('weeklyChart'),
data.weeklyData.labels, chartValues(data.weeklyData), '週業績', fmtMoney);
}
if (data.dowData) {
verticalBar(document.getElementById('dowChart'),
data.dowData.labels, chartValues(data.dowData), '日業績', fmtMoney);
}
if (data.hourlyData) {
verticalBar(document.getElementById('hourlyChart'),
data.hourlyData.labels, chartValues(data.hourlyData), '時業績', fmtMoney);
}
// 10. Heatmap (DOW × Hour) — stacked bar fallback
const hmEl = document.getElementById('heatmapChart');
const heatmapMatrix = data.heatmapData && data.heatmapData.matrix;
if (hmEl && Array.isArray(heatmapMatrix) && heatmapMatrix.some(hasDrawableValues)) {
prepareChartCanvas(hmEl);
const dows = data.heatmapData.dows || ['週一','週二','週三','週四','週五','週六','週日'];
const hours = data.heatmapData.hours || Array.from({length: 24}, (_, i) => `${i}:00`);
const datasets = hours.map((h, i) => ({
label: h,
data: data.heatmapData.matrix.map(row => row[i] || 0),
backgroundColor: mix(ACCENT, Math.max(8, (i / 24) * 80)),
stack: 'hm'
}));
new Chart(hmEl, {
type: 'bar',
data: { labels: dows, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => `${c.dataset.label}: ${fmtMoney(c.parsed.y)}` } } },
scales: {
x: { stacked: true, grid: { display: false }, ticks: baseTicks },
y: { stacked: true, grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmtMoney(v) } }
}
}
});
} else {
renderChartEmpty(hmEl, '所選期間沒有可交叉分析的星期與小時資料。');
}
};
// ─── YoY ───────────────────────────────────────────────
let yoyChart = null;
window.loadYoYData = function () {
const y1 = document.getElementById('yoy-year1').value;
const y2 = document.getElementById('yoy-year2').value;
const m = document.getElementById('yoy-metric').value;
const url = buildPeriodApiUrl('/api/sales_analysis/yoy_comparison', {
year1: y1,
year2: y2,
metric: m
});
fetch(url)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(d => {
const breakdown = Array.isArray(d.monthly_breakdown) ? d.monthly_breakdown : [];
const total1 = Number(d.year1 && d.year1.total || 0);
const total2 = Number(d.year2 && d.year2.total || 0);
document.getElementById('yoy-year1-label').textContent = d.year1 && d.year1.label || `${y1}`;
document.getElementById('yoy-year2-label').textContent = d.year2 && d.year2.label || `${y2}`;
document.getElementById('yoy-year1-value').textContent = fmtMoney(total1);
document.getElementById('yoy-year2-value').textContent = fmtMoney(total2);
const growth = Number(d.growth_rate || 0);
const card = document.getElementById('yoy-growth-card');
card.classList.toggle('is-decline', growth < 0);
const arrow = growth >= 0 ? 'up' : 'down';
document.getElementById('yoy-growth-value').innerHTML =
`<i class="fas fa-arrow-${arrow}"></i> ${growth.toFixed(1)}%`;
const ctx = document.getElementById('yoy-chart');
if (yoyChart) yoyChart.destroy();
if (!breakdown.length || (!hasDrawableValues(breakdown.map(row => row.year1_value)) && !hasDrawableValues(breakdown.map(row => row.year2_value)))) {
renderChartEmpty(ctx, '所選期間與比較年度沒有可繪製的月別資料。');
return;
}
prepareChartCanvas(ctx);
yoyChart = new Chart(ctx, {
type: 'line',
data: {
labels: breakdown.map(row => row.month_label),
datasets: [
{ label: `${y1}`, data: breakdown.map(row => Number(row.year1_value || 0)), borderColor: MUTED,
backgroundColor: mix(MUTED, 8), tension: 0.3, fill: false, borderWidth: 2 },
{ label: `${y2}`, data: breakdown.map(row => Number(row.year2_value || 0)), borderColor: ACCENT,
backgroundColor: mix(ACCENT, 12), tension: 0.3, fill: true, borderWidth: 2.5 }
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { color: INK, boxWidth: 12 } } },
scales: {
x: { grid: { display: false }, ticks: baseTicks },
y: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmtMoney(v) } }
}
}
});
})
.catch(e => {
console.warn('[yoy] fetch failed', e);
renderChartEmpty(document.getElementById('yoy-chart'), '年度比較資料暫時無法載入。');
});
};
// ─── DataTable ─────────────────────────────────────────
const initDataTable = () => {
const $tbl = window.jQuery && jQuery('#dataTable');
if (!$tbl || !$tbl.length || !jQuery.fn.DataTable) return;
const url = buildPeriodApiUrl('/api/sales_analysis/table_data');
const numericFields = new Set(['rank', 'avg_price', 'margin_rate', 'return_rate', 'qty', 'amount']);
const columns = Array.from($tbl[0].querySelectorAll('thead th')).map((header) => {
const field = header.dataset.field;
return {
data: field,
defaultContent: '—',
render(value, type) {
if (type !== 'display') return numericFields.has(field) ? Number(value || 0) : value;
if (value === null || value === undefined || value === '') return '—';
if (field === 'amount' || field === 'avg_price') return fmtMoney(value);
if (field === 'margin_rate' || field === 'return_rate') return `${Number(value).toFixed(1)}%`;
if (field === 'qty' || field === 'rank') return fmtNum(value);
const node = document.createElement('span');
node.textContent = String(value);
return node.innerHTML;
}
};
});
$tbl.DataTable({
ajax: { url, dataSrc: 'data' },
columns,
processing: true, serverSide: false,
pageLength: 25, lengthMenu: [10, 25, 50, 100],
order: [[$tbl.find('thead th').length - 1, 'desc']],
language: typeof window.EwoooCDataTableLanguage === 'function'
? window.EwoooCDataTableLanguage()
: {},
columnDefs: [{ targets: '_all', defaultContent: '—' }]
});
};
// ─── Boot ──────────────────────────────────────────────
const boot = () => {
document.documentElement.dataset.salesCharts = 'loading';
try {
initFlatpickr();
const data = readData();
if (data) buildCharts(data);
initDataTable();
if (typeof window.loadYoYData === 'function' && document.getElementById('yoy-chart')) {
window.loadYoYData();
}
if (window.bootstrap) {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
}
document.documentElement.dataset.salesCharts = 'ready';
} catch (error) {
document.documentElement.dataset.salesCharts = 'error';
document.documentElement.dataset.salesChartsError = error && error.message ? error.message : String(error);
console.error('[sales-analysis] chart boot failed:', error);
} finally {
hideLoading();
}
};
// Show loading on form submit
document.addEventListener('submit', (e) => {
if (e.target.matches('form[action="/sales_analysis"]')) showLoading('正在查詢...');
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();