798 lines
43 KiB
JavaScript
798 lines
43 KiB
JavaScript
/* ════════════════════════════════════════════════════════
|
||
* 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(`<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}`, safeHtmlText(row.division), safeHtmlText(row.area_name || '-'), safeHtmlText(row.pm_name || '-'),
|
||
`<span class="text-primary fw-600">${safeHtmlText(row.brand_name)}</span>`,
|
||
`<span class="small text-muted">${safeHtmlText(row.vendor_name)}</span>`,
|
||
`<span class="badge bg-light text-dark border">${safeHtmlText(row.trade_type || '-')}</span>`,
|
||
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(`<tr><td class="text-muted py-3">${message}</td></tr>`);
|
||
}
|
||
}
|
||
|
||
// ── 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>${safeHtmlText(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, 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 = `
|
||
<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>${currentSeriesLabel}</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>${previousSeriesLabel}</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: [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 = `
|
||
<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, 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 => `<strong>${safeHtmlText(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: 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) + '<br/>' + 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('<br/>')
|
||
},
|
||
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 => `<div style="font-weight:bold">${safeHtmlText(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, 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 => `<div style="font-weight:bold">${safeHtmlText(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, 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 = `<thead class="table-light"><tr>
|
||
<th>排名</th><th>廠商名稱</th><th class="text-end">總銷售額</th><th class="text-end">銷售額 YoY</th>
|
||
<th class="text-end">${previousLabel}</th><th class="text-end">${currentLabel}</th>
|
||
<th class="text-end">總毛利額</th><th class="text-end">毛利額 YoY</th>
|
||
<th class="text-end">${previousLabel}毛利</th><th class="text-end">${currentLabel}毛利</th></tr></thead><tbody>`;
|
||
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 += `<tr>
|
||
<td>${i+1}</td>
|
||
<td class="text-start text-truncate" style="max-width:200px;">${safeHtmlText(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">${previousSales.toLocaleString()}</td>
|
||
<td class="text-end text-primary fw-bold">${currentSales.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">${previousProfit.toLocaleString()}</td>
|
||
<td class="text-end text-success fw-bold">${currentProfit.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, _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 => `<strong>${safeHtmlText(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, 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) + '<br/>' + ps.map(p => `${p.marker} ${safeHtmlText(p.seriesName)}: ${(p.value || 0).toLocaleString()}`).join('<br/>') },
|
||
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 = `<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">${safeHtmlText(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;
|
||
})();
|