/* ========================================================= page-daily-sales.js Chart.js 多圖控制 + 行事曆/日期切換邏輯 配色從 CSS var 讀取(analysis-chart-theme.js 已注入 Chart.defaults) ========================================================= */ (function () { 'use strict'; function readDailySalesData() { const node = document.getElementById('daily-sales-data'); if (!node && window.__DAILY_SALES__) return window.__DAILY_SALES__; if (!node) return null; const rawPayload = [ node.content && node.content.textContent, node.textContent, node.innerHTML ].find(value => value && value.trim()); if (!rawPayload) { console.error('[daily_sales] chart data is empty'); return null; } try { return JSON.parse(rawPayload); } catch (error) { console.error('[daily_sales] chart data parse failed:', error); return null; } } const dailySalesData = readDailySalesData(); if (!dailySalesData) return; window.__DAILY_SALES__ = dailySalesData; let chartsRendered = false; const isCompact = () => window.matchMedia && window.matchMedia('(max-width: 768px)').matches; // -- Palette 讀自 CSS variable (analysis-chart-theme.js 公開) ---------- function cssVar(name, fallback) { const v = getComputedStyle(document.documentElement) .getPropertyValue(name).trim(); return v || fallback; } function rgba(hex, alpha) { if (!hex) return `rgba(0,0,0,${alpha})`; let c = hex.replace('#', ''); if (c.length === 3) c = c.split('').map(x => x + x).join(''); const num = parseInt(c, 16); if (Number.isNaN(num)) return hex; const r = (num >> 16) & 255, g = (num >> 8) & 255, b = num & 255; return `rgba(${r},${g},${b},${alpha})`; } const palette = { caramel: cssVar('--momo-page-accent', '#c96442'), honey: cssVar('--momo-tag-honey', '#b88416'), rust: cssVar('--momo-tag-rust', '#b5342f'), mahogany: cssVar('--momo-tag-mahogany', '#8f4530'), olive: cssVar('--momo-tag-olive', '#6f7a3a'), clay: cssVar('--momo-tag-clay', '#a0613a'), muted: cssVar('--momo-text-muted', '#7e6f5c') }; function loadChartJs() { if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) { return window.EwoooCChartTheme.loadChartJs(); } if (window.Chart) return Promise.resolve(window.Chart); return Promise.reject(new Error('Chart.js loader unavailable')); } function applyChartDefaults() { Chart.defaults.color = palette.muted; Chart.defaults.borderColor = rgba(palette.muted, 0.18); Chart.defaults.font.family = "'Noto Sans TC', 'Inter', system-ui, sans-serif"; } // -- Safe data extraction --------------------------------------------- const cd = dailySalesData.chartData || {}; const competitor = dailySalesData.competitor || {}; const competitorTrend = competitor.trend || {}; const competitorCoverage = competitor.coverage || {}; const categoryChart = dailySalesData.categoryChart || {}; const safe = { labels: cd.labels || [], revenue: cd.revenue || [], profit: cd.profit || [], margin_rate: cd.margin_rate || (cd.revenue || []).map((revenue, index) => { const rev = Number(revenue || 0); if (!rev) return 0; return Number((cd.profit || [])[index] || 0) / rev * 100; }), avg_price: cd.avg_price || [], qty: cd.qty || [], dod_revenue: cd.dod_revenue || [], dod_profit: cd.dod_profit || [], dod_avg_price: cd.dod_avg_price || [], dod_qty: cd.dod_qty || [], wow_revenue: cd.wow_revenue || [], wow_profit: cd.wow_profit || [], wow_avg_price: cd.wow_avg_price || [], wow_qty: cd.wow_qty || [], top10_labels: cd.top10_labels || [], top10_values: cd.top10_values || [] }; const chartInstances = []; function rememberChart(chart) { if (chart) chartInstances.push(chart); return chart; } function stabilizeCharts() { window.requestAnimationFrame(() => { chartInstances.forEach(chart => { if (!chart) return; if (typeof chart.resize === 'function') chart.resize(); if (typeof chart.update === 'function') chart.update('none'); }); window.dispatchEvent(new Event('resize')); }); } function formatShort(value, mode) { const n = Number(value || 0); if (mode === 'pct') return `${n.toFixed(1)}%`; if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`; return Math.round(n).toLocaleString(); } function formatMetric(value, mode) { const n = Number(value || 0); if (mode === 'pct') return `${n >= 0 ? '+' : ''}${n.toFixed(1)}%`; if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`; return Math.round(n).toLocaleString(); } function axisMoney(title) { return { beginAtZero: true, grace: '8%', title: { display: !isCompact(), text: title }, ticks: { callback: value => formatMetric(value, 'currency') } }; } function axisPercent(title) { return { beginAtZero: false, grace: '12%', title: { display: !isCompact(), text: title }, ticks: { callback: value => formatMetric(value, 'pct') }, grid: { color: context => Number(context.tick.value) === 0 ? rgba(palette.muted, 0.38) : rgba(palette.muted, 0.12), lineWidth: context => Number(context.tick.value) === 0 ? 1.2 : 1 } }; } function renderHtmlBars(canvasId, labels, values, options = {}) { const canvas = document.getElementById(canvasId); const wrap = canvas ? canvas.closest('.chart-container') : null; if (!wrap) return; wrap.classList.add('chart-fallback-active'); canvas.setAttribute('aria-hidden', 'true'); if (wrap.querySelector('.chart-fallback-list, .chart-fallback-bars')) return; const pairs = (labels || []).map((label, index) => ({ label: String(label || ''), value: Number((values || [])[index] || 0) })).filter(item => Number.isFinite(item.value)); const data = options.limit ? pairs.slice(-options.limit) : pairs; if (!data.length) return; const max = Math.max(...data.map(item => Math.abs(item.value)), 1); const chart = document.createElement('div'); chart.className = `chart-fallback-bars ${options.horizontal ? 'is-horizontal' : 'is-vertical'}`; data.forEach(item => { const bar = document.createElement('div'); bar.className = `chart-fallback-bar ${item.value < 0 ? 'is-negative' : ''}`; const pct = Math.max(4, Math.round(Math.abs(item.value) / max * 100)); if (options.horizontal) { bar.style.setProperty('--bar-w', `${pct}%`); } else { bar.style.setProperty('--bar-h', `${pct}%`); } const label = document.createElement('span'); label.className = 'chart-fallback-label'; label.textContent = item.label.length > 10 ? `${item.label.slice(0, 10)}...` : item.label; const value = document.createElement('strong'); value.className = 'chart-fallback-value'; value.textContent = formatShort(item.value, options.mode); bar.append(label, value); chart.appendChild(bar); }); wrap.appendChild(chart); } function renderHtmlChartFallbacks() { renderHtmlBars('trendChart', safe.labels, safe.revenue, { mode: 'currency', limit: 14 }); renderHtmlBars('dodChart', safe.labels, safe.dod_revenue, { mode: 'pct', limit: 14 }); renderHtmlBars('wowChart', safe.labels, safe.wow_revenue, { mode: 'pct', limit: 14 }); renderHtmlBars('top10Chart', safe.top10_labels, safe.top10_values, { mode: 'currency', horizontal: true }); const mk = dailySalesData.marketing || {}; if (mk.discount) renderHtmlBars('discountChart', mk.discount.labels, mk.discount.values, { mode: 'currency', horizontal: true }); if (mk.coupon) renderHtmlBars('couponChart', mk.coupon.labels, mk.coupon.values, { mode: 'currency', horizontal: true }); if (competitorTrend.labels) { renderHtmlBars('competitorGapChart', competitorTrend.labels, competitorTrend.avg_gap_pct, { mode: 'pct', limit: 14 }); } renderHtmlBars('marginChart', safe.labels, safe.margin_rate, { mode: 'pct', limit: 14 }); renderHtmlBars('avgQtyChart', safe.labels, safe.avg_price, { mode: 'currency', limit: 14 }); if (categoryChart.labels) { renderHtmlBars('categoryRevenueChart', categoryChart.labels, categoryChart.revenue, { mode: 'currency', horizontal: true }); } const coverage = buildCoverageFunnel(); renderHtmlBars('competitorCoverageChart', coverage.labels, coverage.values, { mode: 'number', horizontal: true }); } function hasSeriesData(labels, ...seriesList) { return Array.isArray(labels) && labels.length > 0 && seriesList.some(series => Array.isArray(series) && series.some(value => Number.isFinite(Number(value)) && Math.abs(Number(value)) > 1e-9) ); } function renderChartEmpty(canvasId, message) { const canvas = document.getElementById(canvasId); const wrap = canvas ? canvas.closest('.chart-container') : null; if (!wrap || wrap.querySelector('.chart-empty-state')) return; wrap.classList.add('chart-empty-active'); canvas.setAttribute('aria-hidden', 'true'); const empty = document.createElement('div'); empty.className = 'chart-empty-state'; empty.innerHTML = `尚無可繪製資料${message}`; wrap.appendChild(empty); } // -- Helpers ---------------------------------------------------------- function makeLineDataset(label, data, color, yAxisID) { return { label, data, borderColor: color, backgroundColor: rgba(color, 0.14), borderWidth: 2.4, tension: 0.34, cubicInterpolationMode: 'monotone', fill: false, pointRadius: context => context.dataIndex === context.dataset.data.length - 1 ? 3 : 1.6, pointHoverRadius: 5, pointHitRadius: 12, yAxisID }; } function makeWowDataset(label, data, color) { const muted = palette.muted; return { ...makeLineDataset(label, data, color, undefined), segment: { borderColor: ctx => ctx.p0DataIndex < 7 ? rgba(muted, 0.6) : color } }; } function buildCoverageFunnel() { return { labels: ['決策支援', '精準告警', '身份配對', '待刷新', '單位價', '待覆核'], values: [ competitorCoverage.decision_support_count ?? competitorCoverage.decision_ready_count ?? 0, competitorCoverage.decision_ready_matches ?? competitorCoverage.fresh_matches ?? 0, competitorCoverage.valid_matches ?? 0, competitorCoverage.stale_matches ?? competitorCoverage.stale_match_count ?? 0, competitorCoverage.unit_comparable_count ?? 0, competitorCoverage.rescore_accepted_count ?? competitorCoverage.review_queue_count ?? 0 ].map(value => Number(value || 0)) }; } // -- Chart 1: trend (multi-line) -------------------------------------- function renderTrend() { const el = document.getElementById('trendChart'); if (!el) return; if (!hasSeriesData(safe.labels, safe.revenue, safe.profit, safe.avg_price, safe.qty)) { renderChartEmpty('trendChart', '目前圖表 payload 沒有日別業績序列,請確認匯入資料與快取狀態。'); return; } rememberChart(new Chart(el, { type: 'line', data: { labels: safe.labels, datasets: [ makeLineDataset('業績', safe.revenue, palette.caramel, 'y'), makeLineDataset('毛利', safe.profit, palette.honey, 'y'), makeLineDataset('客單價', safe.avg_price, palette.mahogany, 'y1'), makeLineDataset('銷量', safe.qty, palette.olive, 'y2') ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => { const metric = ctx.dataset.yAxisID === 'y2' ? 'number' : 'currency'; return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, metric)}`; } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } }, y: { type: 'linear', position: 'left', ...axisMoney('業績 / 毛利') }, y1: { type: 'linear', position: 'right', ...axisMoney('客單價'), grid: { drawOnChartArea: false }, }, y2: { type: 'linear', position: 'right', display: false, beginAtZero: true, grid: { drawOnChartArea: false } } } } })); } // -- Chart 2: DoD (multi-line %) -------------------------------------- function renderDod() { const el = document.getElementById('dodChart'); if (!el) return; if (!hasSeriesData(safe.labels, safe.dod_revenue, safe.dod_profit, safe.dod_avg_price, safe.dod_qty)) { renderChartEmpty('dodChart', '目前沒有 Day-over-Day 成長率序列。'); return; } rememberChart(new Chart(el, { type: 'line', data: { labels: safe.labels, datasets: [ makeLineDataset('業績 DoD%', safe.dod_revenue, palette.caramel), makeLineDataset('毛利 DoD%', safe.dod_profit, palette.honey), makeLineDataset('客單 DoD%', safe.dod_avg_price, palette.mahogany), makeLineDataset('銷量 DoD%', safe.dod_qty, palette.olive) ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, 'pct')}` } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } }, y: axisPercent('DoD 成長率') } } })); } // -- Chart 3: WoW (multi-line %, 前 7 天淡灰) ------------------------- function renderWow() { const el = document.getElementById('wowChart'); if (!el) return; if (!hasSeriesData(safe.labels, safe.wow_revenue, safe.wow_profit, safe.wow_avg_price, safe.wow_qty)) { renderChartEmpty('wowChart', '目前沒有 Week-over-Week 成長率序列。'); return; } rememberChart(new Chart(el, { type: 'line', data: { labels: safe.labels, datasets: [ makeWowDataset('業績 WoW%', safe.wow_revenue, palette.caramel), makeWowDataset('毛利 WoW%', safe.wow_profit, palette.honey), makeWowDataset('客單 WoW%', safe.wow_avg_price, palette.mahogany), makeWowDataset('銷量 WoW%', safe.wow_qty, palette.olive) ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => { const v = ctx.parsed.y; const i = ctx.dataIndex; if (i < 7 || v === 0) { return `${ctx.dataset.label}: 無對比資料(需上週同日數據)`; } return `${ctx.dataset.label}: ${formatMetric(v, 'pct')}`; } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } }, y: axisPercent('WoW 成長率') } } })); } // -- Chart 4: Top 10 (橫向 bar) --------------------------------------- function renderTop10() { const el = document.getElementById('top10Chart'); if (!el) return; if (!hasSeriesData(safe.top10_labels, safe.top10_values)) { renderChartEmpty('top10Chart', '所選日期沒有可排序的商品業績資料。'); return; } rememberChart(new Chart(el, { type: 'bar', data: { labels: safe.top10_labels, datasets: [{ label: '銷售金額', data: safe.top10_values, backgroundColor: rgba(palette.caramel, 0.62), borderColor: palette.caramel, borderWidth: 1, maxBarThickness: 22 }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.x, 'currency') } } }, scales: { x: axisMoney('銷售金額'), y: { grid: { display: false }, ticks: { autoSkip: false } } } } })); } // -- Chart 5: Margin rate trend -------------------------------------- function renderMarginRate() { const el = document.getElementById('marginChart'); if (!el) return; if (!hasSeriesData(safe.labels, safe.margin_rate)) { renderChartEmpty('marginChart', '目前沒有可計算的毛利率序列。'); return; } rememberChart(new Chart(el, { type: 'line', data: { labels: safe.labels, datasets: [ makeLineDataset('毛利率', safe.margin_rate, palette.olive), { label: '30 日均線', data: safe.margin_rate.map((_, index, values) => { const start = Math.max(0, index - 6); const sample = values.slice(start, index + 1).map(Number).filter(Number.isFinite); return sample.length ? sample.reduce((sum, value) => sum + value, 0) / sample.length : 0; }), borderColor: palette.honey, backgroundColor: rgba(palette.honey, 0.1), borderWidth: 1.8, borderDash: [5, 5], tension: 0.28, pointRadius: 0 } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, 'pct')}` } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } }, y: axisPercent('毛利率') } } })); } // -- Chart 6: AOV x Qty ---------------------------------------------- function renderAvgQty() { const el = document.getElementById('avgQtyChart'); if (!el) return; if (!hasSeriesData(safe.labels, safe.avg_price, safe.qty)) { renderChartEmpty('avgQtyChart', '目前沒有客單價或銷量序列。'); return; } rememberChart(new Chart(el, { type: 'bar', data: { labels: safe.labels, datasets: [ { label: '銷量', data: safe.qty, backgroundColor: rgba(palette.olive, 0.24), borderColor: palette.olive, borderWidth: 1, maxBarThickness: 24, yAxisID: 'y' }, { label: '客單價', data: safe.avg_price, type: 'line', borderColor: palette.mahogany, backgroundColor: rgba(palette.mahogany, 0.12), borderWidth: 2.2, tension: 0.32, pointRadius: 2, yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => { const mode = ctx.dataset.yAxisID === 'y1' ? 'currency' : 'number'; return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, mode)}`; } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } }, y: { beginAtZero: true, title: { display: !isCompact(), text: '銷量' } }, y1: { position: 'right', grid: { drawOnChartArea: false }, ...axisMoney('客單價') } } } })); } // -- Chart 7: Category revenue --------------------------------------- function renderCategoryRevenue() { const el = document.getElementById('categoryRevenueChart'); if (!el) return; if (!hasSeriesData(categoryChart.labels, categoryChart.revenue, categoryChart.profit)) { renderChartEmpty('categoryRevenueChart', '目前沒有分類業績彙總可繪製。'); return; } rememberChart(new Chart(el, { type: 'bar', data: { labels: categoryChart.labels || [], datasets: [ { label: '業績', data: categoryChart.revenue || [], backgroundColor: rgba(palette.caramel, 0.5), borderColor: palette.caramel, borderWidth: 1, maxBarThickness: 22 }, { label: '毛利', data: categoryChart.profit || [], backgroundColor: rgba(palette.olive, 0.42), borderColor: palette.olive, borderWidth: 1, maxBarThickness: 22 } ] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.x, 'currency')}` } } }, scales: { x: axisMoney('金額'), y: { grid: { display: false }, ticks: { autoSkip: false, callback: function (value) { const label = this.getLabelForValue(value); return label.length > 14 ? `${label.slice(0, 14)}…` : label; } } } } } })); } // -- Chart 8: Competitor gap pressure -------------------------------- function renderCompetitorGap() { const el = document.getElementById('competitorGapChart'); if (!el) return; const labels = competitorTrend.labels || []; const avgGap = competitorTrend.avg_gap_pct || []; const riskCount = competitorTrend.risk_count || []; if (!hasSeriesData(labels, avgGap, riskCount)) { renderChartEmpty('competitorGapChart', '目前沒有高信心 PChome 歷史價差序列。'); return; } rememberChart(new Chart(el, { type: 'bar', data: { labels, datasets: [ { label: 'MOMO 較 PChome 貴 SKU 數', data: riskCount, backgroundColor: rgba(palette.rust, 0.28), borderColor: palette.rust, borderWidth: 1, maxBarThickness: 24, yAxisID: 'y' }, { label: '平均價差 %', data: avgGap, type: 'line', borderColor: palette.caramel, backgroundColor: rgba(palette.caramel, 0.12), borderWidth: 2.2, tension: 0.32, pointRadius: 2, yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => { const mode = ctx.dataset.yAxisID === 'y1' ? 'pct' : 'number'; return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, mode)}`; } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } }, y: { beginAtZero: true, title: { display: !isCompact(), text: '風險 SKU 數' } }, y1: { position: 'right', grid: { drawOnChartArea: false }, ...axisPercent('平均價差') } } } })); } // -- Chart 9: Competitor decision coverage --------------------------- function renderCompetitorCoverage() { const el = document.getElementById('competitorCoverageChart'); if (!el) return; const coverage = buildCoverageFunnel(); if (!hasSeriesData(coverage.labels, coverage.values)) { renderChartEmpty('competitorCoverageChart', '目前尚未形成可繪製的比價覆蓋資料。'); return; } rememberChart(new Chart(el, { type: 'bar', data: { labels: coverage.labels, datasets: [{ label: 'SKU 數', data: coverage.values, backgroundColor: [ rgba(palette.caramel, 0.62), rgba(palette.olive, 0.54), rgba(palette.honey, 0.54), rgba(palette.rust, 0.38), rgba(palette.mahogany, 0.32), rgba(palette.muted, 0.24) ], borderColor: [ palette.caramel, palette.olive, palette.honey, palette.rust, palette.mahogany, palette.muted ], borderWidth: 1, maxBarThickness: 18 }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.x, 'number')}` } } }, scales: { x: { beginAtZero: true, ticks: { precision: 0 } }, y: { grid: { display: false } } } } })); } // -- Marketing charts ---------------------------------------------- function renderMarketingBar(elId, marketing, color) { const el = document.getElementById(elId); if (!el || !marketing) return; if (!hasSeriesData(marketing.labels, marketing.values)) { renderChartEmpty(elId, '所選期間沒有對應的行銷活動業績資料。'); return; } const shades = Array.from({ length: marketing.labels.length }, (_, i) => { const a = 0.8 - i * 0.05; return rgba(color, Math.max(a, 0.25)); }); rememberChart(new Chart(el.getContext('2d'), { type: 'bar', data: { labels: marketing.labels, datasets: [{ label: '業績', data: marketing.values, backgroundColor: shades, borderColor: color, borderWidth: 1, maxBarThickness: 22 }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.x, 'currency') } } }, scales: { x: axisMoney('業績'), y: { grid: { display: false }, ticks: { autoSkip: false, font: { size: 11 }, callback: function (v) { const lbl = this.getLabelForValue(v); return lbl.length > 25 ? lbl.substr(0, 25) + '…' : lbl; } } } }, onClick: () => exportMarketingData() } })); } // -- DataTables init ------------------------------------------------ function initDataTable() { if (typeof $ === 'undefined' || !$.fn.DataTable) return; $('#categoryTable').DataTable({ order: [[2, 'desc']], pageLength: 25, language: { url: 'https://cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' } }); } // -- Navigation handlers ------------------------------------------- function changeDate() { const sel = document.getElementById('dateSelector'); if (!sel) return; window.location.href = `/daily_sales?date=${sel.value}`; } function toggleDateSelection(clickedDate, currentSelectedDate) { const isMonthView = window.__DAILY_SALES__.isMonthView; if (isMonthView) { window.location.href = `/daily_sales?date=${clickedDate}`; return; } if (clickedDate === currentSelectedDate) { window.backToMonthView(); } else { window.location.href = `/daily_sales?date=${clickedDate}`; } } function backToMonthView() { const params = new URLSearchParams(window.location.search); const month = params.get('month'); window.location.href = month ? `/daily_sales?month=${month}` : '/daily_sales'; } function changeMonth(month) { window.location.href = `/daily_sales?month=${month}`; } // -- Export handlers ----------------------------------------------- function exportCategoryTable() { const sel = document.getElementById('dateSelector'); if (!sel || !sel.value) { alert('請先選擇日期'); return; } window.location.href = `/daily_sales/export?date=${sel.value}`; } function exportMarketingData() { const sel = document.getElementById('dateSelector'); const date = sel ? sel.value : ''; const params = new URLSearchParams(window.location.search); const isMonthView = !params.has('date'); let url = '/daily_sales/export_marketing?type=all'; if (isMonthView) { const month = params.get('month') || new Date().toISOString().slice(0, 7); const [y, m] = month.split('-'); const start = `${y}-${m}-01`; const end = new Date(y, m, 0).toISOString().slice(0, 10); url += `&start_date=${start}&end_date=${end}`; } else { url += `&date=${date}`; } window.location.href = url; } function initDailySalesActions() { const page = document.querySelector('.daily-sales-page'); const selectedDate = page ? page.dataset.selectedDate : ''; document.querySelectorAll('[data-daily-date-selector]').forEach(select => { select.addEventListener('change', changeDate); }); document.querySelectorAll('[data-daily-action="month-view"]').forEach(control => { control.addEventListener('click', event => { event.preventDefault(); backToMonthView(); }); control.addEventListener('keydown', event => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); backToMonthView(); }); }); document.querySelectorAll('[data-daily-month]').forEach(button => { button.addEventListener('click', () => changeMonth(button.dataset.dailyMonth)); }); document.querySelectorAll('.cal-day[data-has-data="true"][data-date]').forEach(day => { day.addEventListener('click', () => toggleDateSelection(day.dataset.date, selectedDate)); day.addEventListener('keydown', event => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); toggleDateSelection(day.dataset.date, selectedDate); }); day.setAttribute('role', 'button'); day.setAttribute('tabindex', '0'); }); document.querySelectorAll('[data-daily-export="marketing"]').forEach(button => { button.addEventListener('click', exportMarketingData); }); document.querySelectorAll('[data-daily-export="category"]').forEach(button => { button.addEventListener('click', exportCategoryTable); }); } // -- Boot ----------------------------------------------------------- function renderAllCharts() { if (chartsRendered) return; chartsRendered = true; applyChartDefaults(); renderTrend(); renderDod(); renderWow(); renderTop10(); renderMarginRate(); renderAvgQty(); renderCategoryRevenue(); renderCompetitorGap(); renderCompetitorCoverage(); const mk = dailySalesData.marketing || {}; if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel); if (mk.coupon) renderMarketingBar('couponChart', mk.coupon, palette.olive); stabilizeCharts(); } function bootCharts() { document.documentElement.dataset.dailyCharts = 'loading'; loadChartJs() .then(() => { renderAllCharts(); document.documentElement.dataset.dailyCharts = 'ready'; }) .catch(error => { document.documentElement.dataset.dailyCharts = 'error'; document.documentElement.dataset.dailyChartsError = error && error.message ? error.message : String(error); console.error('[daily_sales] Chart.js 載入失敗:', error); renderHtmlChartFallbacks(); }); } function scheduleChartBoot() { window.setTimeout(bootCharts, 0); } function observeCharts() { const targets = Array.from(document.querySelectorAll('.chart-card')); if (!targets.length) return; if (!('IntersectionObserver' in window)) { bootCharts(); return; } const observer = new IntersectionObserver(entries => { if (!entries.some(entry => entry.isIntersecting)) return; observer.disconnect(); bootCharts(); }, { rootMargin: '260px 0px' }); targets.forEach(target => observer.observe(target)); } document.addEventListener('DOMContentLoaded', function () { initDailySalesActions(); initDataTable(); scheduleChartBoot(); }); })();