/* =========================================================
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)`;
// ─── 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 = `${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 horizontalBar = (canvas, labels, values, label, fmt) => {
if (!canvas || !labels || !labels.length) return null;
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 }
}
}
});
};
const doughnut = (canvas, labels, values) => {
if (!canvas || !labels || !labels.length) return null;
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 || !labels || !labels.length) return null;
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: 3, pointHoverRadius: 5, 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 },
y: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() }, beginAtZero: true }
}
}
});
};
const verticalBar = (canvas, labels, values, label, fmt) => {
if (!canvas || !labels || !labels.length) return null;
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: { 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, data.barData.values,
data.barData.label || '銷售',
data.barData.is_money ? fmtMoney : fmtNum
);
}
// 2. Category doughnut
if (data.categoryData) {
doughnut(document.getElementById('categoryChart'),
data.categoryData.labels, data.categoryData.values);
}
// 3. Treemap
const treemapEl = document.getElementById('treemapChart');
if (treemapEl && data.treemapData && data.treemapData.length) {
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)}` : '' } }
}
}
});
}
// 4. Price distribution (vertical bar)
if (data.priceDistData) {
verticalBar(document.getElementById('priceDistChart'),
data.priceDistData.labels, data.priceDistData.values,
'業績', fmtMoney);
}
// 5. Scatter
const scEl = document.getElementById('scatterChart');
if (scEl && data.scatterData && data.scatterData.length) {
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 }
}
}
});
}
// 6. BCG matrix
const bcgEl = document.getElementById('bcgChart');
if (bcgEl && data.bcgData && data.bcgData.points && data.bcgData.points.length) {
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)]
});
}
// 7. Seasonality heatmap (matrix-style via grouped bars stacked – fallback)
const seasEl = document.getElementById('seasonalityChart');
if (seasEl && data.seasonalityData && data.seasonalityData.matrix) {
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) } }
}
}
});
}
// 8. Marketing — discount + coupon
if (data.marketingData) {
if (data.marketingData.discount) {
horizontalBar(document.getElementById('mktDiscountChart'),
data.marketingData.discount.labels, data.marketingData.discount.values,
'折扣業績', fmtMoney);
}
if (data.marketingData.coupon) {
horizontalBar(document.getElementById('mktCouponChart'),
data.marketingData.coupon.labels, data.marketingData.coupon.values,
'折價券業績', fmtMoney);
}
}
// 9. Time-dimension trends
if (data.monthlyData) {
lineTrend(document.getElementById('monthlyChart'),
data.monthlyData.labels, data.monthlyData.values, '月業績', fmtMoney);
}
if (data.weeklyData) {
lineTrend(document.getElementById('weeklyChart'),
data.weeklyData.labels, data.weeklyData.values, '週業績', fmtMoney);
}
if (data.dowData) {
verticalBar(document.getElementById('dowChart'),
data.dowData.labels, data.dowData.values, '日業績', fmtMoney);
}
if (data.hourlyData) {
verticalBar(document.getElementById('hourlyChart'),
data.hourlyData.labels, data.hourlyData.values, '時業績', fmtMoney);
}
// 10. Heatmap (DOW × Hour) — stacked bar fallback
const hmEl = document.getElementById('heatmapChart');
if (hmEl && data.heatmapData && data.heatmapData.matrix) {
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) } }
}
}
});
}
};
// ─── 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;
fetch(`/api/yoy?year1=${y1}&year2=${y2}&metric=${m}&${window.location.search.slice(1)}`)
.then(r => r.json())
.then(d => {
document.getElementById('yoy-year1-label').textContent = `${y1}年`;
document.getElementById('yoy-year2-label').textContent = `${y2}年`;
document.getElementById('yoy-year1-value').textContent = fmtMoney(d.total1 || 0);
document.getElementById('yoy-year2-value').textContent = fmtMoney(d.total2 || 0);
const growth = d.growth || 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 =
` ${growth.toFixed(1)}%`;
const ctx = document.getElementById('yoy-chart');
if (yoyChart) yoyChart.destroy();
yoyChart = new Chart(ctx, {
type: 'line',
data: {
labels: d.labels || [],
datasets: [
{ label: `${y1}年`, data: d.values1 || [], borderColor: MUTED,
backgroundColor: mix(MUTED, 8), tension: 0.3, fill: false, borderWidth: 2 },
{ label: `${y2}年`, data: d.values2 || [], 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));
};
// ─── DataTable ─────────────────────────────────────────
const initDataTable = () => {
const $tbl = window.jQuery && jQuery('#dataTable');
if (!$tbl || !$tbl.length || !jQuery.fn.DataTable) return;
const url = '/api/sales_table?' + window.location.search.slice(1);
$tbl.DataTable({
ajax: { url, dataSrc: 'data' },
processing: true, serverSide: false,
pageLength: 25, lengthMenu: [10, 25, 50, 100],
order: [[$tbl.find('thead th').length - 1, 'desc']],
language: { url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' },
columnDefs: [{ targets: '_all', defaultContent: '—' }]
});
};
// ─── Boot ──────────────────────────────────────────────
const boot = () => {
initFlatpickr();
const data = readData();
if (data) buildCharts(data);
initDataTable();
if (typeof window.loadYoYData === 'function' && document.getElementById('yoy-chart')) {
try { window.loadYoYData(); } catch (e) { /* silent */ }
}
// tooltips
if (window.bootstrap) {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
}
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();
}
})();