/* ════════════════════════════════════════════════════════ * page-monthly-summary.js — Turn C * ──────────────────────────────────────────────────────── * monthly_summary_analysis.html 的 ECharts + DataTable 邏輯 * * 注意:依賴 analysis-chart-theme.js 對 echarts.init 的攔截, * 它會自動把 option.color 換成 .momo-app[data-page-group] 的 * --momo-page-chart-* 暖色調 palette,因此本檔保留原 itemStyle.color * 邏輯時,會被自動覆寫;分類/條件配色(如 BCG 象限)保持顯式指定。 * ════════════════════════════════════════════════════════ */ (function () { 'use strict'; let table; let compareChart, yoyTrendChart, vendorRankingChart, divisionDistChart, priceRangeChart; let bcgMatrixChart, priceVolumeScatterChart, seasonalityHeatmapChart, areaRankingChart; let specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart; let activeFetchController = null; let fetchRunId = 0; let lastMainData = null; const lastSpecialData = new Map(); const REQUEST_TIMEOUT_MS = 20000; const initialParams = new URLSearchParams(window.location.search); let currentFilters = { year: initialParams.get('year') || '', month: initialParams.get('month') || '', start_month: initialParams.get('start_month') || '', end_month: initialParams.get('end_month') || '', area: initialParams.get('area_name') || '', vendor: initialParams.get('vendor') || '', trade: initialParams.get('trade_type') || '' }; const compactMediaQuery = window.matchMedia('(max-width: 768px)'); function isCompactViewport() { return compactMediaQuery.matches; } function cssColor(name, fallback) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; } function decodeText(value) { const textarea = document.createElement('textarea'); textarea.innerHTML = String(value ?? ''); return textarea.value; } function safeHtmlText(value) { const span = document.createElement('span'); span.textContent = decodeText(value); return span.innerHTML; } $(function () { compareChart = echarts.init(document.getElementById('compareChart')); yoyTrendChart = echarts.init(document.getElementById('yoyTrendChart')); vendorRankingChart = echarts.init(document.getElementById('vendorRankingChart')); divisionDistChart = echarts.init(document.getElementById('divisionDistChart')); priceRangeChart = echarts.init(document.getElementById('priceRangeChart')); bcgMatrixChart = echarts.init(document.getElementById('bcgMatrixChart')); priceVolumeScatterChart = echarts.init(document.getElementById('priceVolumeScatterChart')); seasonalityHeatmapChart = echarts.init(document.getElementById('seasonalityHeatmapChart')); areaRankingChart = echarts.init(document.getElementById('areaRankingChart')); specialChart = echarts.init(document.getElementById('specialChart')); bodyCareChart = echarts.init(document.getElementById('bodyCareChart')); makeupFragranceChart = echarts.init(document.getElementById('makeupFragranceChart')); privacyInfantChart = echarts.init(document.getElementById('privacyInfantChart')); table = $('#summaryTable').DataTable({ language: typeof window.EwoooCDataTableLanguage === 'function' ? window.EwoooCDataTableLanguage() : {}, autoWidth: false, deferRender: true, pageLength: 20, order: [[0, 'desc'], [7, 'desc']], columnDefs: [ { targets: [7], className: 'text-end fw-bold' }, { targets: 8, className: 'text-center' } ] }); window.addEventListener('resize', () => { [compareChart, yoyTrendChart, vendorRankingChart, divisionDistChart, priceRangeChart, bcgMatrixChart, priceVolumeScatterChart, seasonalityHeatmapChart, areaRankingChart, specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart] .forEach(c => c && c.resize()); }); const refreshForViewport = () => { if (lastMainData) updateUI(lastMainData); lastSpecialData.forEach(({ chart, tbody, payload }) => { renderExcelChart(chart, tbody, payload.trend, payload.period); }); }; if (compactMediaQuery.addEventListener) { compactMediaQuery.addEventListener('change', refreshForViewport); } else if (compactMediaQuery.addListener) { compactMediaQuery.addListener(refreshForViewport); } fetchData(); }); // ── API ─────────────────────────────────────────────── function buildFilterParams({ limit = '', areaOverride = null } = {}) { const params = new URLSearchParams(); if (limit) params.set('limit', limit); ['year', 'month', 'start_month', 'end_month'].forEach(key => { if (currentFilters[key]) params.set(key, currentFilters[key]); }); const area = areaOverride === null ? currentFilters.area : areaOverride; if (area) params.set('area_name', area); if (currentFilters.vendor) params.set('vendor', currentFilters.vendor); if (currentFilters.trade) params.set('trade_type', currentFilters.trade); return params; } function syncFilterUrl() { const params = buildFilterParams(); const query = params.toString(); window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`); } function updateAnalysisTabLinks(period) { if (!period || !period.start_date || !period.end_date) return; const targets = { sales: `/sales_analysis?start_date=${period.start_date}&end_date=${period.end_date}`, daily_sales: `/daily_sales?month=${period.end_month}`, growth: `/growth_analysis?start_month=${period.start_month}&end_month=${period.end_month}`, monthly: `/monthly_summary_analysis?start_month=${period.start_month}&end_month=${period.end_month}` }; Object.entries(targets).forEach(([target, href]) => { const link = document.querySelector(`[data-analysis-target="${target}"]`); if (link) link.setAttribute('href', href); }); } function setReportError(message = '') { const state = document.getElementById('monthlyReportErrorState'); if (!state) return; state.hidden = !message; const detail = document.getElementById('monthlyReportErrorMessage'); if (detail) detail.textContent = message; } async function fetchJson(url, { signal, timeoutMs = REQUEST_TIMEOUT_MS } = {}) { const controller = new AbortController(); const relayAbort = () => controller.abort(); if (signal) { if (signal.aborted) controller.abort(); else signal.addEventListener('abort', relayAbort, { once: true }); } const timeout = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } finally { window.clearTimeout(timeout); if (signal) signal.removeEventListener('abort', relayAbort); } } async function fetchData() { const runId = ++fetchRunId; if (activeFetchController) activeFetchController.abort(); const controller = new AbortController(); activeFetchController = controller; document.documentElement.dataset.monthlyCharts = 'loading'; setReportError(); $('#loadingOverlay').css('display', 'flex'); const url = `/api/monthly_summary_data?${buildFilterParams({ limit: '2000' }).toString()}`; const specialPromise = fetchSpecialChartData(controller.signal, runId); try { const data = await fetchJson(url, { signal: controller.signal }); if (runId !== fetchRunId) return; if (data.status !== 'success') throw new Error(data.message || '月結資料回應失敗'); lastMainData = data; updateUI(data); updateFilters(data.filters); updateAnalysisTabLinks(data.period); document.documentElement.dataset.monthlyCharts = 'ready'; } catch (error) { if (runId !== fetchRunId) return; const timedOut = error && error.name === 'AbortError'; const message = timedOut ? '月結資料載入逾時,已停止本次請求;請稍後重新整理。' : '月結資料暫時無法載入,圖表已停止更新。'; setReportError(message); document.documentElement.dataset.monthlyCharts = 'error'; document.documentElement.dataset.monthlyChartsError = error && error.message ? error.message : String(error); console.error('[monthly-summary] main data failed', error); } finally { if (runId === fetchRunId) $('#loadingOverlay').hide(); } await specialPromise; if (runId === fetchRunId) { activeFetchController = null; } } async function fetchSpecialChartData(signal, runId) { document.documentElement.dataset.monthlySpecialCharts = 'loading'; const pairs = [ ['開架保養,臉部清潔', specialChart, 'specialChartTableBody'], ['身體保養', bodyCareChart, 'bodyCareChartTableBody'], ['彩妝/指彩,精油擴香', makeupFragranceChart, 'makeupFragranceChartTableBody'], ['私密保養,嬰幼洗沐', privacyInfantChart, 'privacyInfantChartTableBody'] ]; await Promise.all(pairs.map(async ([area, chart, tbody]) => { const params = buildFilterParams({ areaOverride: area }); try { const payload = await fetchJson(`/api/monthly_summary_trend?${params.toString()}`, { signal, timeoutMs: 12000 }); if (runId !== fetchRunId) return; if (payload.status !== 'success') throw new Error(payload.message || '區域趨勢回應失敗'); lastSpecialData.set(area, { chart, tbody, payload }); renderExcelChart(chart, tbody, payload.trend, payload.period); } catch (error) { if (runId !== fetchRunId || (signal && signal.aborted)) return; renderChartEmpty(chart, tbody, '此區域趨勢暫時無法載入。'); console.error('[monthly-summary] special trend failed', area, error); } })); if (runId === fetchRunId) document.documentElement.dataset.monthlySpecialCharts = 'ready'; } // ── Filter ──────────────────────────────────────────── function selectFilter(type, value) { currentFilters[type] = value; if (type === 'year' || type === 'month') { currentFilters.start_month = ''; currentFilters.end_month = ''; } const btnMap = { year:'btnYear', month:'btnMonth', area:'btnArea', vendor:'btnVendor', trade:'btnTrade' }; const defaultMap = { year:'最新年份', month:'全部月份', area:'所有區域', vendor:'所有廠商', trade:'所有類別' }; $(`#${btnMap[type]}`).text(decodeText(value || defaultMap[type])); syncFilterUrl(); fetchData(); } window.selectFilter = selectFilter; function applyMonthRange() { const start = document.getElementById('periodStartMonth').value; const end = document.getElementById('periodEndMonth').value; currentFilters.start_month = start || end; currentFilters.end_month = end || start; currentFilters.year = ''; currentFilters.month = ''; syncFilterUrl(); fetchData(); } window.applyMonthRange = applyMonthRange; function resetMonthRange() { currentFilters.year = ''; currentFilters.month = ''; currentFilters.start_month = ''; currentFilters.end_month = ''; document.getElementById('periodStartMonth').value = ''; document.getElementById('periodEndMonth').value = ''; syncFilterUrl(); fetchData(); } window.resetMonthRange = resetMonthRange; function filterList(input) { const filter = input.value.toLowerCase(); const list = input.closest('ul').querySelectorAll('li:not(:first-child)'); list.forEach(li => { const t = (li.textContent || li.innerText).toLowerCase(); li.style.display = t.indexOf(filter) > -1 ? '' : 'none'; }); } window.filterList = filterList; function updateFilters(f) { const populate = (id, list, type) => { const el = $(id); if (el.children().length > 1) return; list.forEach(value => { const item = document.createElement('li'); const link = document.createElement('a'); link.className = 'dropdown-item'; link.href = '#'; link.textContent = decodeText(value); link.addEventListener('click', event => { event.preventDefault(); selectFilter(type, value); }); item.appendChild(link); el.append(item); }); }; populate('#listYear', f.years, 'year'); populate('#listMonth', f.months.map(String).sort((a,b) => a - b), 'month'); populate('#listArea', f.areas, 'area'); populate('#listTrade', f.trades, 'trade'); populate('#listVendor', f.vendors, 'vendor'); const labels = { year: currentFilters.year || '最新年份', month: currentFilters.month || '全部月份', area: currentFilters.area || '所有區域', vendor: currentFilters.vendor || '所有廠商', trade: currentFilters.trade || '所有類別' }; $('#btnYear').text(decodeText(labels.year)); $('#btnMonth').text(decodeText(labels.month)); $('#btnArea').text(decodeText(labels.area)); $('#btnVendor').text(decodeText(labels.vendor)); $('#btnTrade').text(decodeText(labels.trade)); } // ── UI updates ──────────────────────────────────────── function updateUI(data) { const period = data.period || {}; $('#activeReportPeriod').text(period.label || '全部資料'); const periodEmpty = Number(data.total_rows || 0) === 0; const periodEmptyState = document.getElementById('monthlyPeriodEmptyState'); if (periodEmptyState) { periodEmptyState.hidden = !periodEmpty; const periodEmptyLabel = document.getElementById('monthlyPeriodEmptyLabel'); if (periodEmptyLabel) periodEmptyLabel.textContent = period.label || '所選期間'; } if (!currentFilters.year && !currentFilters.month && !currentFilters.start_month && period.start_month) { currentFilters.start_month = period.start_month; currentFilters.end_month = period.end_month; syncFilterUrl(); } if (period.start_month) document.getElementById('periodStartMonth').value = period.start_month; if (period.end_month) document.getElementById('periodEndMonth').value = period.end_month; $('#totalRows').text(data.total_rows.toLocaleString()); $('#totalMonths').text(data.total_months); const k = data.kpis || {}; $('#kpiSales').text('$' + (k.sales || 0).toLocaleString()); $('#kpiProfit').text('$' + (k.profit || 0).toLocaleString()); $('#kpiMargin').text(k.margin || '-'); $('#kpiVol').text((k.vol || 0).toLocaleString()); if (k.sales_yoa > 0) { const yoy = ((k.sales - k.sales_yoa) / k.sales_yoa * 100).toFixed(1); const cls = yoy >= 0 ? 'text-danger' : 'text-success'; $('#kpiSalesYoY').html(` ${yoy}% vs 去年同期`); } else { $('#kpiSalesYoY').text('-'); } table.clear(); (data.rows || []).forEach(row => { const sales = row.sales_amt_curr || 0; const prevYear = row.sales_amt_yoa || 0; let yoyHtml = '-'; if (prevYear > 0) { const yoy = ((sales - prevYear) / prevYear * 100).toFixed(1); const cls = yoy >= 0 ? 'text-danger' : 'text-success'; yoyHtml = `${yoy > 0 ? '+' : ''}${yoy}%`; } table.row.add([ `${row.year}/${row.month}`, safeHtmlText(row.division), safeHtmlText(row.area_name || '-'), safeHtmlText(row.pm_name || '-'), `${safeHtmlText(row.brand_name)}`, `${safeHtmlText(row.vendor_name)}`, `${safeHtmlText(row.trade_type || '-')}`, sales.toLocaleString(), yoyHtml ]); }); table.draw(); renderExcelChart(compareChart, 'compareChartTableBody', data.trend, period); updateHighlights(data.highlights); renderYoYTrendChart(yoyTrendChart, 'yoyTrendChartTableBody', data.yoy_trend); renderVendorRankingChart(vendorRankingChart, 'vendorRankingChartTableBody', data.vendor_ranking, period); renderDivisionDistChart(divisionDistChart, 'divisionDistChartTableBody', data.division_dist, period); renderPriceRangeChart(priceRangeChart, 'priceRangeChartTableBody', data.price_contribution, period); renderBCGMatrixChart(bcgMatrixChart, data.bcg_data); renderScatterChart(priceVolumeScatterChart, data.bcg_data); renderSeasonalityHeatmap(seasonalityHeatmapChart, data.heatmap_data, period); renderAreaRankingChart(areaRankingChart, 'areaRankingChartTableBody', data.area_ranking, period); } function renderChartEmpty(chart, tableBodyId, message) { if (!chart) return; chart.clear(); chart.setOption({ title: { text: '尚無可繪製資料', subtext: message, left: 'center', top: 'middle', textStyle: { color: '#3f3a34', fontSize: 15, fontWeight: 700 }, subtextStyle: { color: '#746b61', fontSize: 12, lineHeight: 18 } }, series: [] }); if (tableBodyId) { $(`#${tableBodyId}`).html(`${message}`); } } // ── Highlights (Top 3) ──────────────────────────────── function updateHighlights(h) { if (!h || (!h.rev_top.length && !h.profit_top.length)) { $('#highlightsRow').hide(); return; } $('#highlightsRow').show(); const r = (id, list, prefix='') => { const t = $(`#${id}`); t.empty(); list.forEach((it, i) => t.append(`${i+1}${safeHtmlText(it.name)}${prefix}${it.value.toLocaleString()}`)); }; r('revHighlightsBody', h.rev_top, '$'); r('profitHighlightsBody', h.profit_top, '$'); r('volHighlightsBody', h.vol_top, ''); } // ── Excel-style 主對比圖 ───────────────────────────── function renderExcelChart(chart, tableBodyId, trend, period = {}) { if (!trend || !trend.length) { renderChartEmpty(chart, tableBodyId, '所選期間與去年同期皆無月結資料。'); return; } chart.clear(); const compact = isCompactViewport(); const years = [...new Set(trend.map(t => t.date.split('/')[0]))].sort().reverse(); const currYear = String(period.current_year || years[0] || new Date().getFullYear()); const prevYear = String(period.previous_year || (parseInt(currYear) - 1)); const currentSeriesLabel = period.label || currYear; const previousSeriesLabel = period.previous_label || prevYear; const hasPeriodRoles = trend.some(t => t.period_role && Number.isInteger(t.slot)); const slots = hasPeriodRoles ? [...new Set(trend.map(t => t.slot))].sort((a, b) => a - b) : [...new Set(trend.map(t => parseInt(t.date.split('/')[1], 10)))].sort((a, b) => a - b); const months = slots.map(slot => { const item = trend.find(t => (hasPeriodRoles ? t.slot : parseInt(t.date.split('/')[1], 10)) === slot); return hasPeriodRoles ? item.slot_label : String(slot); }); const currData = new Array(months.length).fill(0); const prevData = new Array(months.length).fill(0); const yoyData = new Array(months.length).fill(0); trend.forEach(t => { const [y, m] = t.date.split('/'); const slot = hasPeriodRoles ? t.slot : parseInt(m, 10); const monthIndex = slots.indexOf(slot); if (monthIndex < 0) return; if ((hasPeriodRoles && t.period_role === 'current') || (!hasPeriodRoles && y === currYear)) currData[monthIndex] = t.sales; if ((hasPeriodRoles && t.period_role === 'previous') || (!hasPeriodRoles && y === prevYear)) prevData[monthIndex] = t.sales; }); for (let i = 0; i < months.length; i++) { if (prevData[i] > 0) yoyData[i] = parseFloat(((currData[i] - prevData[i]) / prevData[i] * 100).toFixed(1)); } const C1 = cssColor('--momo-warm-caramel', '#c96442'); const C2 = cssColor('--momo-warm-olive', '#6f7a4a'); const CM = cssColor('--momo-text-tertiary', '#94a3b8'); const tableHtml = ` 月份${months.map(m => `${m}`).join('')} ${currentSeriesLabel}${currData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} ${previousSeriesLabel}${prevData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} YOY${yoyData.map((v, i) => `${(currData[i] > 0 || prevData[i] > 0) ? v + '%' : '-'}`).join('')}`; $(`#${tableBodyId}`).html(tableHtml); chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: [currentSeriesLabel, previousSeriesLabel, 'YOY'], bottom: 0, show: false }, grid: { left: compact ? '42' : '80', right: compact ? '16' : '50', top: compact ? '26' : '50', bottom: compact ? '28' : '20' }, xAxis: { type: 'category', data: months, axisLabel: { show: compact, interval: 0, fontSize: 10 }, axisTick: { show: true } }, yAxis: [ { type: 'value', name: compact ? '' : '業績', axisLabel: { formatter: v => compact ? (v/10000).toFixed(0) + '萬' : v.toLocaleString(), fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } }, { type: 'value', name: compact ? '' : 'YoY', axisLabel: { formatter: '{value}%', fontSize: compact ? 10 : 12 }, splitLine: { show: false } } ], series: [ { name: currentSeriesLabel, type: 'bar', data: currData, barMaxWidth: compact ? 12 : 20, label: { show: !compact, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } }, { name: previousSeriesLabel, type: 'bar', data: prevData, barMaxWidth: compact ? 12 : 20, label: { show: !compact, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } }, { name: 'YOY', type: 'line', yAxisIndex: 1, data: yoyData, smooth: true, lineStyle: { width: compact ? 1.5 : 2 }, symbol: 'circle', symbolSize: compact ? 4 : 6, label: { show: !compact, position: 'bottom', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? p.value + '%' : '' } } ] }); } // ── YoY Trend ───────────────────────────────────────── function renderYoYTrendChart(chart, tableId, data) { if (!data || !data.length) { renderChartEmpty(chart, tableId, '所選期間尚無本期業績,未以其他月份回填。'); return; } chart.clear(); const compact = isCompactViewport(); const dates = data.map(d => d.date.split('/')[1] + '月'); const currData = data.map(d => d.curr); const yoaData = data.map(d => d.yoa); const years = [...new Set(data.map(d => d.date.split('/')[0]))]; const currYearStr = years[0] || '本期'; let html = ` 月份${dates.map(m => `${m}`).join('')} ${currYearStr}${currData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} 去年同期${yoaData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')}`; $(`#${tableId}`).html(html); chart.setOption({ tooltip: { trigger: 'axis' }, grid: { left: compact ? '42' : '60', right: compact ? '14' : '30', top: compact ? '24' : '40', bottom: compact ? '26' : '20' }, legend: { data: [currYearStr, '去年同期'], bottom: 0, show: !compact }, xAxis: { type: 'category', data: dates, axisLabel: { fontSize: compact ? 10 : 12 } }, yAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 12 } }, series: [ { name: currYearStr, type: 'line', data: currData, lineStyle: { width: 3 }, showSymbol: false, areaStyle: { opacity: 0.12 } }, { name: '去年同期', type: 'line', data: yoaData, lineStyle: { type: 'dashed' }, showSymbol: false } ] }); } // ── 類別分佈 (雙圓餅) ───────────────────────────────── function renderDivisionDistChart(chart, _tableId, data, period = {}) { if (!data || !data.length) { renderChartEmpty(chart, _tableId, '所選期間與去年同期皆無類別業績。'); return; } chart.clear(); const compact = isCompactViewport(); const previousLabel = period.previous_label || `${period.previous_year || ''} 年`; const currentLabel = period.label || `${period.current_year || ''} 年`; const pie = key => data.map(d => ({ name: decodeText(d.name), value: d[key] || 0 })).filter(d => d.value > 0); chart.setOption({ title: [ { text: previousLabel, left: '23%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } }, { text: currentLabel, left: '73%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } } ], tooltip: { trigger: 'item', formatter: p => `${safeHtmlText(p.name)}
業績: ${(p.value/10000).toFixed(0)}萬
佔比: ${p.percent.toFixed(1)}%` }, legend: { type: 'scroll', orient: 'horizontal', bottom: 0, show: !compact, textStyle: { fontSize: 12, fontWeight: 'bold' } }, series: [ { name: previousLabel, type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['25%', compact ? '53%' : '55%'], data: pie('sales_previous'), label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%` } }, { name: currentLabel, type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['75%', compact ? '53%' : '55%'], data: pie('sales_current'), label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%` } } ] }); } // ── 價格帶 ──────────────────────────────────────────── function renderPriceRangeChart(chart, tableId, data, period = {}) { if (!data || !data.length) { renderChartEmpty(chart, tableId, '所選期間與去年同期皆無價格帶資料。'); return; } chart.clear(); const compact = isCompactViewport(); const order = ['0-499','500-999','1,000-1,999','2,000-4,999','5,000-9,999','10,000+']; data.sort((a,b) => order.indexOf(a.range) - order.indexOf(b.range)); const previousLabel = period.previous_label || '去年同期'; const currentLabel = period.label || '本期'; const previousTotal = data.reduce((s, x) => s + (x.sales_previous || 0), 0); const currentTotal = data.reduce((s, x) => s + (x.sales_current || 0), 0); chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: ps => safeHtmlText(ps[0].name) + '
' + ps.map(p => { const v = p.value || 0; const total = p.seriesName === previousLabel ? previousTotal : currentTotal; const pct = total > 0 ? (v/total*100).toFixed(1) + '%' : '0.0%'; return `${p.marker} ${safeHtmlText(p.seriesName)}: ${v.toLocaleString()} (${pct})`; }).join('
') }, legend: { data: [previousLabel, currentLabel], bottom: 0, show: !compact, textStyle: { fontSize: 13, fontWeight: 'bold' } }, grid: { left: compact ? '2%' : '3%', right: compact ? '2%' : '4%', bottom: compact ? '18%' : '12%', top: compact ? '8%' : '15%', containLabel: true }, xAxis: { type: 'category', data: data.map(d => d.range), axisLabel: { interval: 0, rotate: compact ? 32 : 0, fontSize: compact ? 10 : 13, fontWeight: 'bold' } }, yAxis: { type: 'value', name: compact ? '' : '銷售額', nameTextStyle: { fontSize: 12, fontWeight: 'bold' }, axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 12 } }, series: [ { label: previousLabel, key: 'sales_previous', total: previousTotal }, { label: currentLabel, key: 'sales_current', total: currentTotal } ].map((series) => ({ name: series.label, type: 'bar', barWidth: compact ? 13 : 30, data: data.map(d => d[series.key] || 0), label: { show: !compact, position: 'top', fontSize: 12, fontWeight: 'bold', formatter: p => { const v = p.value || 0; const total = series.total; const pct = total > 0 ? (v/total*100).toFixed(1) + '%' : ''; return pct + '\n' + (v/10000).toFixed(0) + '萬'; } } })) }); $(`#${tableId}`).parent().html(''); } // ── BCG 矩陣(4 象限顯式配色) ─────────────────────── function renderBCGMatrixChart(chart, data) { if (!data || !data.length) { renderChartEmpty(chart, null, '所選期間尚無可建立品牌策略矩陣的本期資料。'); return; } chart.clear(); const compact = isCompactViewport(); const C_STAR = cssColor('--momo-warm-olive', '#6f7a4a'); const C_COW = cssColor('--momo-warm-honey', '#c89043'); const C_QUESTION = cssColor('--momo-warm-caramel', '#c96442'); const C_DOG = cssColor('--momo-warm-mahogany', '#7a3b2c'); chart.setOption({ tooltip: { formatter: p => `
${safeHtmlText(p.value[3])}
毛利率: ${p.value[0]}%
銷售額: $${p.value[1].toLocaleString()}
銷量: ${p.value[2].toLocaleString()}
` }, grid: { left: compact ? '46' : '80', right: compact ? '16' : '50', top: compact ? '18' : '30', bottom: compact ? '34' : '30' }, xAxis: { name: compact ? '' : '毛利率(%)', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } }, yAxis: { name: compact ? '' : '業績($)', type: 'value', axisLabel: { formatter: v => compact ? (v/10000).toFixed(0) + '萬' : v.toLocaleString(), fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } }, series: [{ type: 'scatter', data: data.map(d => [d.margin, d.sales, d.qty, decodeText(d.name)]), symbolSize: d => Math.min(compact ? 52 : 96, Math.max(compact ? 8 : 10, Math.sqrt(Math.max(d[2], 1)) * (compact ? 0.35 : 0.75))), itemStyle: { color: p => { if (p.value[0] >= 30 && p.value[1] >= 500000) return C_STAR; if (p.value[0] < 30 && p.value[1] >= 500000) return C_COW; if (p.value[0] >= 30 && p.value[1] < 500000) return C_QUESTION; return C_DOG; }, opacity: 0.78, borderColor: '#fff8ef', borderWidth: 1 }, markLine: { silent: true, data: [ { xAxis: 30, lineStyle: { color: '#999' }, label: { show: !compact, formatter: '高獲利線 (30%)' } }, { yAxis: 500000, lineStyle: { color: '#999' }, label: { show: !compact, formatter: '高營收線 ($50萬)' } } ] } }] }); } // ── 散佈圖(價/量) ────────────────────────────────── function renderScatterChart(chart, data) { if (!data || !data.length) { renderChartEmpty(chart, null, '所選期間尚無可比較價格與銷量的本期資料。'); return; } chart.clear(); const compact = isCompactViewport(); const series = data.map(d => { const avg = d.qty > 0 ? Math.round(d.sales / d.qty) : 0; return [avg, d.qty, d.sales, decodeText(d.name)]; }); chart.setOption({ tooltip: { formatter: p => `
${safeHtmlText(p.value[3])}
均價: $${p.value[0]}
銷量: ${p.value[1].toLocaleString()}
業績: $${p.value[2].toLocaleString()}
` }, grid: { left: compact ? '42' : '60', right: compact ? '16' : '50', top: compact ? '18' : '30', bottom: compact ? '32' : '30' }, xAxis: { name: compact ? '' : '均價($)', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { show: false } }, yAxis: { name: compact ? '' : '銷量', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } }, series: [{ type: 'scatter', data: series, symbolSize: d => Math.min(compact ? 36 : 64, Math.max(compact ? 6 : 8, Math.log(Math.max(d[2],1)) * (compact ? 1.35 : 2))), itemStyle: { opacity: 0.65 } }] }); } // ── 廠商排行 ────────────────────────────────────────── function renderVendorRankingChart(chart, tableId, data, period = {}) { if (!data || !data.length) { renderChartEmpty(chart, tableId, '所選期間與去年同期皆無廠商業績。'); return; } chart.clear(); const compact = isCompactViewport(); const cd = data.slice().reverse(); const visibleCount = compact ? 10 : 18; const zoomStart = cd.length > visibleCount ? Math.max(0, 100 - (visibleCount / cd.length * 100)) : 0; const previousLabel = period.previous_label || '去年同期'; const currentLabel = period.label || '本期'; chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: [previousLabel, currentLabel], bottom: 0, show: !compact, textStyle: { fontSize: 15, fontWeight: 'bold' } }, grid: { left: compact ? '86' : '160', right: compact ? '34' : '120', top: compact ? '10' : '20', bottom: compact ? '28' : '45' }, dataZoom: cd.length > visibleCount ? [ { type: 'inside', yAxisIndex: 0, start: zoomStart, end: 100 }, { type: 'slider', yAxisIndex: 0, start: zoomStart, end: 100, right: 3, width: 10, showDetail: false } ] : [], xAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, yAxis: { type: 'category', data: cd.map(d => decodeText(d.name)), axisLabel: { width: compact ? 78 : 150, overflow: 'truncate', interval: 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, series: [ { name: previousLabel, type: 'bar', data: cd.map(d => d.sales_previous || 0), barWidth: compact ? 10 : 18, barGap: '0%', barCategoryGap: compact ? '70%' : '75%', label: { show: !compact, position: 'insideRight', fontSize: 13, fontWeight: 'bold', color: '#fff8ef', textShadowColor: 'rgba(42,37,32,0.5)', textShadowBlur: 2, formatter: p => p.value > 0 ? (p.value/10000).toFixed(0) + '萬' : '' } }, { name: currentLabel, type: 'bar', data: cd.map(d => d.sales_current || 0), barWidth: compact ? 10 : 18, label: { show: !compact, position: 'right', fontSize: 14, fontWeight: 'bold', formatter: p => p.value > 0 ? (p.value/10000).toFixed(0) + '萬' : '' } } ] }); let html = ` 排名廠商名稱總銷售額銷售額 YoY ${previousLabel}${currentLabel} 總毛利額毛利額 YoY ${previousLabel}毛利${currentLabel}毛利`; data.forEach((d, i) => { const previousSales = d.sales_previous || 0, currentSales = d.sales_current || 0; const previousProfit = d.profit_previous || 0, currentProfit = d.profit_current || 0; const sYoY = previousSales > 0 ? ((currentSales-previousSales)/previousSales*100).toFixed(1)+'%' : '-'; const pYoY = previousProfit > 0 ? ((currentProfit-previousProfit)/previousProfit*100).toFixed(1)+'%' : '-'; const sCls = previousSales > 0 && (currentSales-previousSales) >= 0 ? 'text-danger' : (previousSales > 0 ? 'text-success' : ''); const pCls = previousProfit > 0 && (currentProfit-previousProfit) >= 0 ? 'text-danger' : (previousProfit > 0 ? 'text-success' : ''); html += ` ${i+1} ${safeHtmlText(d.name)} ${d.sales.toLocaleString()} ${sYoY} ${previousSales.toLocaleString()} ${currentSales.toLocaleString()} ${d.profit.toLocaleString()} ${pYoY} ${previousProfit.toLocaleString()} ${currentProfit.toLocaleString()}`; }); html += ``; $(`#${tableId}`).parent().html(`${html}
`); } // ── 季節熱力圖 ──────────────────────────────────────── function renderSeasonalityHeatmap(chart, data, _period = {}) { if (!data || !data.length) { renderChartEmpty(chart, null, '所選期間與去年同期皆無季節分佈資料。'); return; } chart.clear(); const compact = isCompactViewport(); const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月']; const areaSales = {}; data.forEach(d => { const category = decodeText(d.category); areaSales[category] = (areaSales[category] || 0) + d.sales; }); const areas = Object.keys(areaSales).sort((a,b) => areaSales[b] - areaSales[a]); const yLabels = []; const years = [...new Set(data.map(d => Number(d.year)))].sort((a, b) => b - a); years.forEach(y => areas.forEach(a => yLabels.push(`${y} ${a}`))); const heatmap = []; data.forEach(d => { const xi = d.month - 1, yi = yLabels.indexOf(`${d.year} ${decodeText(d.category)}`); if (xi >= 0 && xi < 12 && yi >= 0) heatmap.push([xi, yi, d.sales]); }); const max = Math.max(...data.map(d => d.sales), 1); chart.setOption({ tooltip: { position: 'top', formatter: p => `${safeHtmlText(yLabels[p.value[1]])}
${months[p.value[0]]}
業績: ${(p.value[2]/10000).toFixed(0)}萬` }, grid: { height: compact ? '72%' : '75%', top: compact ? '3%' : '5%', bottom: compact ? '20%' : '18%', left: compact ? '72' : '15%', right: compact ? '5' : '3%' }, xAxis: { type: 'category', data: months, splitArea: { show: true }, axisLabel: { fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, yAxis: { type: 'category', data: yLabels, splitArea: { show: true }, axisLabel: { width: compact ? 66 : 120, overflow: 'truncate', fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, visualMap: { min: 0, max, calculable: true, orient: 'horizontal', left: 'center', bottom: '0%', textStyle: { fontSize: compact ? 10 : 12 }, inRange: { color: ['#fef3c7', '#f5c98a', '#e3a560', '#c96442', '#7a3b2c'] } }, series: [{ type: 'heatmap', data: heatmap, label: { show: !compact, fontSize: 12, fontWeight: 'bold', formatter: p => (p.value[2]/10000).toFixed(0) + '萬' }, emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.45)' } } }] }); } // ── 區域排行 ────────────────────────────────────────── function renderAreaRankingChart(chart, tableId, data, period = {}) { if (!data || !data.length) { renderChartEmpty(chart, tableId, '所選期間與去年同期皆無區域業績。'); return; } chart.clear(); const compact = isCompactViewport(); const previousLabel = period.previous_label || '去年同期'; const currentLabel = period.label || '本期'; data.sort((a,b) => b.sales - a.sales); chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: ps => safeHtmlText(ps[0].name) + '
' + ps.map(p => `${p.marker} ${safeHtmlText(p.seriesName)}: ${(p.value || 0).toLocaleString()}`).join('
') }, legend: { data: [previousLabel, currentLabel], bottom: 0, show: !compact, textStyle: { fontSize: 14 } }, grid: { left: compact ? '2%' : '3%', right: compact ? '2%' : '4%', bottom: compact ? '20%' : '10%', top: compact ? '8%' : '10%', containLabel: true }, xAxis: { type: 'category', data: data.map(d => decodeText(d.name)), axisLabel: { interval: 0, rotate: compact ? 28 : 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, yAxis: { type: 'value', name: compact ? '' : '業績', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 13 } }, series: [ { label: previousLabel, key: 'sales_previous' }, { label: currentLabel, key: 'sales_current' } ].map(series => ({ name: series.label, type: 'bar', data: data.map(d => d[series.key] || 0), label: { show: !compact, position: 'top', fontSize: 14, fontWeight: 'bold', formatter: p => (p.value/10000).toFixed(0) + '萬' } })) }); let html = `排名區名稱業績`; data.forEach((d, i) => { html += `${i+1}${safeHtmlText(d.name)}$${d.sales.toLocaleString()}`; }); html += ``; $(`#${tableId}`).parent().html(`${html}
`); } // 公開 fetchData 給 onclick 使用 window.fetchData = fetchData; })();