Files
ewoooc/web/static/js/page-sales-analysis.js
ogt ec40d161af
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(analytics): align yoy and detail table contracts
2026-07-15 17:07:59 +08:00

592 lines
24 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 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;
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();
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));
};
// ─── 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: { 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();
}
})();