Files
ewoooc/web/static/js/page-monthly-summary.js
OoO 93dd1f12af
All checks were successful
CD Pipeline / deploy (push) Successful in 57s
優化月總表手機圖表排版
2026-05-13 22:17:47 +08:00

503 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ════════════════════════════════════════════════════════
* page-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 currentFilters = { year: '', month: '', area: '', vendor: '', trade: '' };
const compactMediaQuery = window.matchMedia('(max-width: 768px)');
function isCompactViewport() {
return compactMediaQuery.matches;
}
$(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: { url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' },
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 = () => fetchData();
if (compactMediaQuery.addEventListener) {
compactMediaQuery.addEventListener('change', refreshForViewport);
} else if (compactMediaQuery.addListener) {
compactMediaQuery.addListener(refreshForViewport);
}
fetchData();
});
// ── API ───────────────────────────────────────────────
function fetchData() {
$('#loadingOverlay').css('display', 'flex');
let url = `/api/monthly_summary_data?limit=2000`;
if (currentFilters.year) url += `&year=${currentFilters.year}`;
if (currentFilters.month) url += `&month=${currentFilters.month}`;
if (currentFilters.area) url += `&area_name=${encodeURIComponent(currentFilters.area)}`;
if (currentFilters.vendor) url += `&vendor=${encodeURIComponent(currentFilters.vendor)}`;
if (currentFilters.trade) url += `&trade_type=${encodeURIComponent(currentFilters.trade)}`;
fetch(url).then(r => r.json()).then(data => {
if (data.status === 'success') { updateUI(data); updateFilters(data.filters); }
$('#loadingOverlay').hide();
}).catch(err => { console.error(err); $('#loadingOverlay').hide(); });
fetchSpecialChartData();
}
function fetchSpecialChartData() {
const pairs = [
['開架保養,臉部清潔', specialChart, 'specialChartTableBody'],
['身體保養', bodyCareChart, 'bodyCareChartTableBody'],
['彩妝/指彩,精油擴香', makeupFragranceChart, 'makeupFragranceChartTableBody'],
['私密保養,嬰幼洗沐', privacyInfantChart, 'privacyInfantChartTableBody']
];
pairs.forEach(([area, chart, tbody]) => {
fetch(`/api/monthly_summary_trend?area_name=${encodeURIComponent(area)}`)
.then(r => r.json())
.then(d => { if (d.status === 'success') renderExcelChart(chart, tbody, d.trend); })
.catch(err => console.error('monthly special trend failed', err));
});
}
// ── Filter ────────────────────────────────────────────
function selectFilter(type, value) {
currentFilters[type] = value;
const btnMap = { year:'btnYear', month:'btnMonth', area:'btnArea', vendor:'btnVendor', trade:'btnTrade' };
const defaultMap = { year:'選擇年份', month:'選擇月份', area:'所有區域', vendor:'所有廠商', trade:'所有類別' };
$(`#${btnMap[type]}`).text(value || defaultMap[type]);
fetchData();
}
window.selectFilter = selectFilter;
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(v => el.append(`<li><a class="dropdown-item" href="#" onclick="selectFilter('${type}', '${v}')">${v}</a></li>`));
};
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');
}
// ── UI updates ────────────────────────────────────────
function updateUI(data) {
$('#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(`<span class="${cls} fw-bold"><i class="fas fa-caret-up"></i> ${yoy}%</span> 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 = `<span class="${cls} fw-bold">${yoy > 0 ? '+' : ''}${yoy}%</span>`;
}
table.row.add([
`${row.year}/${row.month}`, row.division, row.area_name || '-', row.pm_name || '-',
`<span class="text-primary fw-600">${row.brand_name}</span>`,
`<span class="small text-muted">${row.vendor_name}</span>`,
`<span class="badge bg-light text-dark border">${row.trade_type || '-'}</span>`,
sales.toLocaleString(), yoyHtml
]);
});
table.draw();
renderExcelChart(compareChart, 'compareChartTableBody', data.trend);
updateHighlights(data.highlights);
renderYoYTrendChart(yoyTrendChart, 'yoyTrendChartTableBody', data.yoy_trend);
renderVendorRankingChart(vendorRankingChart, 'vendorRankingChartTableBody', data.vendor_ranking);
renderDivisionDistChart(divisionDistChart, 'divisionDistChartTableBody', data.division_dist);
renderPriceRangeChart(priceRangeChart, 'priceRangeChartTableBody', data.price_contribution);
renderBCGMatrixChart(bcgMatrixChart, data.bcg_data);
renderScatterChart(priceVolumeScatterChart, data.bcg_data);
renderSeasonalityHeatmap(seasonalityHeatmapChart, data.heatmap_data);
renderAreaRankingChart(areaRankingChart, 'areaRankingChartTableBody', data.area_ranking);
}
// ── 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(`<tr><td class="ps-3"><span class="badge bg-secondary rounded-pill me-2">${i+1}</span>${it.name}</td><td class="text-end pe-3 fw-bold">${prefix}${it.value.toLocaleString()}</td></tr>`));
};
r('revHighlightsBody', h.rev_top, '$');
r('profitHighlightsBody', h.profit_top, '$');
r('volHighlightsBody', h.vol_top, '');
}
// ── Excel-style 主對比圖 ─────────────────────────────
function renderExcelChart(chart, tableBodyId, trend) {
if (!trend) return;
const compact = isCompactViewport();
const years = [...new Set(trend.map(t => t.date.split('/')[0]))].sort().reverse();
const currYear = years[0] || String(new Date().getFullYear());
const prevYear = String(parseInt(currYear) - 1);
const months = Array.from({ length: 12 }, (_, i) => String(i + 1));
const currData = new Array(12).fill(0);
const prevData = new Array(12).fill(0);
const yoyData = new Array(12).fill(0);
trend.forEach(t => {
const [y, m] = t.date.split('/');
if (y === currYear) currData[parseInt(m) - 1] = t.sales;
if (y === prevYear) prevData[parseInt(m) - 1] = t.sales;
});
for (let i = 0; i < 12; i++) {
if (prevData[i] > 0) yoyData[i] = parseFloat(((currData[i] - prevData[i]) / prevData[i] * 100).toFixed(1));
}
const C1 = 'var(--momo-warm-caramel, #c96442)';
const C2 = 'var(--momo-warm-olive, #6f7a4a)';
const CM = 'var(--momo-text-tertiary, #94a3b8)';
const tableHtml = `
<tr><td class="ms-cmp__hd">月份</td>${months.map(m => `<td class="ms-cmp__mh">${m}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><span class="ms-cmp__sw" style="background:${C1}"></span>${currYear}</td>${currData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><span class="ms-cmp__sw" style="background:${C2}"></span>${prevYear}</td>${prevData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><i class="fas fa-minus" style="color:${CM};margin-right:5px;"></i>YOY</td>${yoyData.map((v, i) => `<td>${(currData[i] > 0 || prevData[i] > 0) ? v + '%' : '-'}</td>`).join('')}</tr>`;
$(`#${tableBodyId}`).html(tableHtml);
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: [currYear, prevYear, '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: currYear, 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: prevYear, 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) return;
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 = `
<tr><td class="ms-cmp__hd">月份</td>${dates.map(m => `<td class="ms-cmp__mh">${m}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><span class="ms-cmp__sw ms-cmp__sw--accent"></span>${currYearStr}</td>${currData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><span class="ms-cmp__sw ms-cmp__sw--muted"></span>去年同期</td>${yoaData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}</tr>`;
$(`#${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) {
if (!data || !data.length) return;
const compact = isCompactViewport();
const pie = key => data.map(d => ({ name: d.name, value: d[key] || 0 })).filter(d => d.value > 0);
chart.setOption({
title: [
{ text: '2024 年', left: '23%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } },
{ text: '2025 年', left: '73%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } }
],
tooltip: { trigger: 'item', formatter: p => `<strong>${p.name}</strong><br/>業績: ${(p.value/10000).toFixed(0)}萬<br/>佔比: ${p.percent.toFixed(1)}%` },
legend: { type: 'scroll', orient: 'horizontal', bottom: 0, show: !compact, textStyle: { fontSize: 12, fontWeight: 'bold' } },
series: [
{ name: '2024 年', type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['25%', compact ? '53%' : '55%'], data: pie('sales_2024'),
label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}\n${p.percent.toFixed(1)}%` } },
{ name: '2025 年', type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['75%', compact ? '53%' : '55%'], data: pie('sales_2025'),
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) {
if (!data || !data.length) return;
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 t24 = data.reduce((s, x) => s + (x.sales_2024 || 0), 0);
const t25 = data.reduce((s, x) => s + (x.sales_2025 || 0), 0);
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: ps => ps[0].name + '<br/>' + ps.map(p => {
const v = p.value || 0;
const total = p.seriesName.includes('2024') ? t24 : t25;
const pct = total > 0 ? (v/total*100).toFixed(1) + '%' : '0.0%';
return `${p.marker} ${p.seriesName}: ${v.toLocaleString()} (${pct})`;
}).join('<br/>')
},
legend: { data: ['2024 銷售', '2025 銷售'], 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: ['2024','2025'].map((y) => ({
name: y + ' 銷售', type: 'bar', barWidth: compact ? 13 : 30,
data: data.map(d => d['sales_' + y] || 0),
label: { show: !compact, position: 'top', fontSize: 12, fontWeight: 'bold',
formatter: p => {
const v = p.value || 0;
const total = y === '2024' ? t24 : t25;
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) return;
const compact = isCompactViewport();
const C_STAR = 'var(--momo-warm-olive, #6f7a4a)';
const C_COW = 'var(--momo-warm-honey, #c89043)';
const C_QUESTION = 'var(--momo-warm-caramel, #c96442)';
const C_DOG = 'var(--momo-warm-mahogany, #7a3b2c)';
chart.setOption({
tooltip: { formatter: p => `<div style="font-weight:bold">${p.value[3]}</div><div>毛利率: ${p.value[0]}%</div><div>銷售額: $${p.value[1].toLocaleString()}</div><div>銷量: ${p.value[2].toLocaleString()}</div>` },
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, 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) return;
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, d.name];
});
chart.setOption({
tooltip: { formatter: p => `<div style="font-weight:bold">${p.value[3]}</div><div>均價: $${p.value[0]}</div><div>銷量: ${p.value[1].toLocaleString()}</div><div>業績: $${p.value[2].toLocaleString()}</div>` },
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) {
if (!data || !data.length) return;
const compact = isCompactViewport();
const cd = data.slice().reverse();
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['2024 銷售額', '2025 銷售額'], bottom: 0, show: !compact, textStyle: { fontSize: 15, fontWeight: 'bold' } },
grid: { left: compact ? '86' : '160', right: compact ? '18' : '110', top: compact ? '10' : '20', bottom: compact ? '28' : '45' },
xAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
yAxis: { type: 'category', data: cd.map(d => d.name), axisLabel: { width: compact ? 78 : 150, overflow: 'truncate', interval: 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
series: [
{ name: '2024 銷售額', type: 'bar', data: cd.map(d => d.sales_2024 || 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: '2025 銷售額', type: 'bar', data: cd.map(d => d.sales_2025 || 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 = `<thead class="table-light"><tr>
<th>排名</th><th>廠商名稱</th><th class="text-end">總銷售額</th><th class="text-end">銷售額 YoY</th>
<th class="text-end">2024 銷售額</th><th class="text-end">2025 銷售額</th>
<th class="text-end">總毛利額</th><th class="text-end">毛利額 YoY</th>
<th class="text-end">2024 毛利額</th><th class="text-end">2025 毛利額</th></tr></thead><tbody>`;
data.forEach((d, i) => {
const s24 = d.sales_2024 || 0, s25 = d.sales_2025 || 0, p24 = d.profit_2024 || 0, p25 = d.profit_2025 || 0;
const sYoY = s24 > 0 ? ((s25-s24)/s24*100).toFixed(1)+'%' : '-';
const pYoY = p24 > 0 ? ((p25-p24)/p24*100).toFixed(1)+'%' : '-';
const sCls = s24 > 0 && (s25-s24) >= 0 ? 'text-danger' : (s24 > 0 ? 'text-success' : '');
const pCls = p24 > 0 && (p25-p24) >= 0 ? 'text-danger' : (p24 > 0 ? 'text-success' : '');
html += `<tr>
<td>${i+1}</td>
<td class="text-start text-truncate" style="max-width:200px;">${d.name}</td>
<td class="text-end fw-bold">${d.sales.toLocaleString()}</td>
<td class="text-end fw-bold ${sCls}">${sYoY}</td>
<td class="text-end text-muted">${s24.toLocaleString()}</td>
<td class="text-end text-primary fw-bold">${s25.toLocaleString()}</td>
<td class="text-end fw-bold">${d.profit.toLocaleString()}</td>
<td class="text-end fw-bold ${pCls}">${pYoY}</td>
<td class="text-end text-muted">${p24.toLocaleString()}</td>
<td class="text-end text-success fw-bold">${p25.toLocaleString()}</td></tr>`;
});
html += `</tbody>`;
$(`#${tableId}`).parent().html(`<table class="table table-bordered table-hover table-sm text-center mb-0 small" style="white-space:nowrap;">${html}</table>`);
}
// ── 季節熱力圖 ────────────────────────────────────────
function renderSeasonalityHeatmap(chart, data) {
if (!data || !data.length) return;
const compact = isCompactViewport();
const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const areaSales = {};
data.forEach(d => { areaSales[d.category] = (areaSales[d.category] || 0) + d.sales; });
const areas = Object.keys(areaSales).sort((a,b) => areaSales[b] - areaSales[a]);
const yLabels = [];
[2025, 2024].forEach(y => areas.forEach(a => yLabels.push(`${y} ${a}`)));
const heatmap = [];
data.forEach(d => {
const xi = d.month - 1, yi = yLabels.indexOf(`${d.year} ${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 => `<strong>${yLabels[p.value[1]]}</strong><br/>${months[p.value[0]]}<br/>業績: <strong>${(p.value[2]/10000).toFixed(0)}萬</strong>` },
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) {
if (!data || !data.length) return;
const compact = isCompactViewport();
data.sort((a,b) => b.sales - a.sales);
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: ps => ps[0].name + '<br/>' + ps.map(p => `${p.marker} ${p.seriesName}: ${(p.value || 0).toLocaleString()}`).join('<br/>') },
legend: { data: ['2024 業績', '2025 業績'], 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 => 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: ['2024','2025'].map(y => ({
name: y + ' 業績', type: 'bar', data: data.map(d => d['sales_' + y] || 0),
label: { show: !compact, position: 'top', fontSize: 14, fontWeight: 'bold', formatter: p => (p.value/10000).toFixed(0) + '萬' }
}))
});
let html = `<thead class="table-light"><tr><th>排名</th><th>區名稱</th><th>業績</th></tr></thead><tbody>`;
data.forEach((d, i) => { html += `<tr><td>${i+1}</td><td class="text-start">${d.name}</td><td class="text-end fw-bold">$${d.sales.toLocaleString()}</td></tr>`; });
html += `</tbody>`;
$(`#${tableId}`).parent().html(`<table class="table table-bordered table-hover table-sm text-center mb-0 small">${html}</table>`);
}
// 公開 fetchData 給 onclick 使用
window.fetchData = fetchData;
})();