/* ========================================================= 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(); } })();