/* ========================================================= 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 = `${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 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('★ 明星 (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, chartValues(data.barData), 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) { if (data.marketingData.discount) { horizontalBar(discountCanvas, data.marketingData.discount.labels, data.marketingData.discount.values, '折扣業績', fmtMoney); } else { renderChartEmpty(discountCanvas, '所選期間沒有折扣活動業績。'); } if (data.marketingData.coupon) { horizontalBar(couponCanvas, data.marketingData.coupon.labels, data.marketingData.coupon.values, '折價券業績', fmtMoney); } 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 = ` ${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: [[$tbl.find('thead th').length - 1, 'desc']], 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(); } }; // 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(); } })();