Files
ewoooc/web/static/js/page-sales-analysis.js

786 lines
32 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) {
const start = form.querySelector('[name="start_date"]');
const end = form.querySelector('[name="end_date"]');
if (start) start.value = '';
if (end) end.value = '';
form.requestSubmit();
}
};
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('/api/sales_analysis/top_detail', window.location.origin);
url.searchParams.set('type', kind);
url.searchParams.set('metric', metric);
url.searchParams.set('view', 'product');
// forward existing filters
new URLSearchParams(window.location.search).forEach((v, k) => {
if (!url.searchParams.has(k)) url.searchParams.set(k, v);
});
const modalElement = document.getElementById('topDetailModal');
const rows = document.getElementById('topDetailRows');
const status = document.getElementById('topDetailStatus');
const title = document.getElementById('topDetailModalLabel');
const exportLink = document.getElementById('topDetailExport');
if (!modalElement || !rows || !status || !window.bootstrap) return;
const labels = {
revenue: '業績主推商品',
margin: '高毛利守價商品',
quantity: '人氣引流商品'
};
title.textContent = labels[kind] || '商品排行明細';
rows.replaceChildren();
status.hidden = false;
status.textContent = '正在載入排行...';
const exportUrl = new URL('/api/sales_analysis/export_top_detail', window.location.origin);
url.searchParams.forEach((value, key) => exportUrl.searchParams.set(key, value));
exportLink.href = exportUrl.toString();
bootstrap.Modal.getOrCreateInstance(modalElement).show();
fetch(url.toString(), { headers: { Accept: 'application/json' } })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((payload) => {
const items = Array.isArray(payload.items) ? payload.items : [];
if (!items.length) {
status.textContent = '所選期間沒有符合條件的商品。';
return;
}
const fragment = document.createDocumentFragment();
items.forEach((item, index) => {
const row = document.createElement('tr');
const values = [
fmtNum(index + 1),
[item.name, item.product_id].filter(Boolean).join(' · '),
item.category || '—',
metric === 'qty' ? fmtNum(item.value) : fmtMoney(item.value),
item.margin_rate == null ? '—' : `${Number(item.margin_rate).toFixed(1)}%`
];
values.forEach((value, cellIndex) => {
const cell = document.createElement('td');
cell.textContent = String(value);
if (cellIndex >= 3) cell.classList.add('text-end');
row.appendChild(cell);
});
fragment.appendChild(row);
});
rows.appendChild(fragment);
status.hidden = true;
})
.catch((error) => {
console.warn('[sales-analysis] top detail failed', error);
status.textContent = '排行明細暫時無法載入,請稍後重試。';
});
};
window.exportMarketingExcel = function (campaign) {
const url = new URL('/api/sales_analysis/export_marketing', window.location.origin);
url.searchParams.set('type', 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');
const clearQuickRange = () => {
const range = document.getElementById('data_range');
if (range) range.value = '';
};
if (start) flatpickr(start, { ...opts, onChange: () => {
clearQuickRange();
if (end && end._flatpickr) end._flatpickr.open();
}});
if (end) flatpickr(end, { ...opts, onChange: clearQuickRange });
};
// ─── 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('高毛利潛力', chartArea.left + 12, chartArea.top + 18);
ctx.fillText('主推明星', x + 12, chartArea.top + 18);
ctx.fillText('待調整', chartArea.left + 12, chartArea.bottom - 8);
ctx.fillText('走量守價', 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.metric_label || 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) {
const marketingMetric = data.marketingData.metric || 'revenue';
const marketingFormatter = marketingMetric === 'qty' ? fmtNum : fmtMoney;
const marketingLabel = marketingMetric === 'qty' ? '活動銷量' :
(marketingMetric === 'profit' ? '活動毛利' : '活動業績');
if (data.marketingData.discount) {
horizontalBar(discountCanvas,
data.marketingData.discount.labels, data.marketingData.discount.values,
marketingLabel, marketingFormatter);
} else {
renderChartEmpty(discountCanvas, '所選期間沒有折扣活動業績。');
}
if (data.marketingData.coupon) {
horizontalBar(couponCanvas,
data.marketingData.coupon.labels, data.marketingData.coupon.values,
marketingLabel, marketingFormatter);
} 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: [],
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();
}
};
document.addEventListener('click', (event) => {
const trigger = event.target.closest('[data-sales-filter-key]');
if (!trigger) return;
event.preventDefault();
window.setFilter(trigger.dataset.salesFilterKey, trigger.dataset.salesFilterValue);
});
// 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();
}
})();