Files
ewoooc/web/static/js/page-sales-analysis.js
OoO 605250619c
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
Frontend V3 responsive production update
2026-05-12 18:27:29 +08:00

551 lines
22 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.
/* =========================================================
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 = `<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;
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 =
`<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: 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();
}
})();