fix(analytics): link report data to selected period
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -59,6 +59,35 @@
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-period-filter {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
padding: var(--momo-space-3, 12px) var(--momo-space-4, 16px);
|
||||
margin: var(--momo-space-3, 12px) 0 var(--momo-space-4, 16px);
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-subtle);
|
||||
border-radius: var(--momo-radius-md, 4px);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-period-filter label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 170px;
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: var(--momo-text-meta);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-period-filter input {
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
color: var(--momo-text-primary);
|
||||
background: var(--momo-bg-canvas);
|
||||
border: 1px solid var(--momo-border-subtle);
|
||||
border-radius: var(--momo-radius-sm, 4px);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-empty-state {
|
||||
display: grid;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
@@ -458,6 +487,36 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-competitor-period-gap {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: start;
|
||||
min-height: 190px;
|
||||
width: 100%;
|
||||
border-left: 4px solid var(--momo-page-accent);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
background: var(--momo-surface-muted, rgba(248, 250, 252, 0.88));
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-competitor-period-gap i {
|
||||
margin-bottom: 12px;
|
||||
color: var(--momo-page-accent);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-competitor-period-gap strong {
|
||||
color: var(--momo-text-primary);
|
||||
font-size: var(--momo-text-body, 14px);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-competitor-period-gap p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: var(--momo-text-body-sm, 13px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.growth-analysis-page .ga-page-head {
|
||||
align-items: flex-start;
|
||||
@@ -474,6 +533,16 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-period-filter {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-period-filter label {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-kpi {
|
||||
min-height: 112px;
|
||||
padding: var(--momo-space-4, 16px);
|
||||
|
||||
@@ -134,6 +134,28 @@
|
||||
top: 12px;
|
||||
z-index: 30;
|
||||
}
|
||||
.ms-period-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 248, 239, 0.18);
|
||||
}
|
||||
.ms-period-bar label {
|
||||
margin: 0;
|
||||
color: rgba(255, 248, 239, 0.82);
|
||||
font-size: var(--momo-text-label, 11px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.ms-period-bar input {
|
||||
min-height: 34px;
|
||||
padding: 5px 8px;
|
||||
color: var(--momo-text-primary);
|
||||
background: var(--momo-surface-raised);
|
||||
border: 1px solid var(--momo-border-subtle);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ms-filter-group__label {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-family: var(--momo-font-display);
|
||||
@@ -365,6 +387,20 @@
|
||||
padding: var(--momo-space-3);
|
||||
}
|
||||
|
||||
.ms-period-bar {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ms-period-bar label {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
.ms-period-bar input {
|
||||
flex: 1 1 132px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ms-filter-group::before {
|
||||
top: 30px;
|
||||
bottom: 4px;
|
||||
|
||||
@@ -833,19 +833,19 @@
|
||||
function changeDate() {
|
||||
const sel = document.getElementById('dateSelector');
|
||||
if (!sel) return;
|
||||
window.location.href = `/daily_sales?date=${sel.value}`;
|
||||
window.location.href = `/daily_sales?date=${sel.value}&month=${sel.value.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
function toggleDateSelection(clickedDate, currentSelectedDate) {
|
||||
const isMonthView = window.__DAILY_SALES__.isMonthView;
|
||||
if (isMonthView) {
|
||||
window.location.href = `/daily_sales?date=${clickedDate}`;
|
||||
window.location.href = `/daily_sales?date=${clickedDate}&month=${clickedDate.slice(0, 7)}`;
|
||||
return;
|
||||
}
|
||||
if (clickedDate === currentSelectedDate) {
|
||||
window.backToMonthView();
|
||||
} else {
|
||||
window.location.href = `/daily_sales?date=${clickedDate}`;
|
||||
window.location.href = `/daily_sales?date=${clickedDate}&month=${clickedDate.slice(0, 7)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,16 @@
|
||||
let bcgMatrixChart, priceVolumeScatterChart, seasonalityHeatmapChart, areaRankingChart;
|
||||
let specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart;
|
||||
|
||||
let currentFilters = { year: '', month: '', area: '', vendor: '', trade: '' };
|
||||
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() {
|
||||
@@ -67,17 +76,49 @@
|
||||
});
|
||||
|
||||
// ── 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 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)}`;
|
||||
const url = `/api/monthly_summary_data?${buildFilterParams({ limit: '2000' }).toString()}`;
|
||||
|
||||
fetch(url).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') { updateUI(data); updateFilters(data.filters); }
|
||||
if (data.status === 'success') {
|
||||
updateUI(data);
|
||||
updateFilters(data.filters);
|
||||
updateAnalysisTabLinks(data.period);
|
||||
}
|
||||
$('#loadingOverlay').hide();
|
||||
}).catch(err => { console.error(err); $('#loadingOverlay').hide(); });
|
||||
|
||||
@@ -92,9 +133,10 @@
|
||||
['私密保養,嬰幼洗沐', privacyInfantChart, 'privacyInfantChartTableBody']
|
||||
];
|
||||
pairs.forEach(([area, chart, tbody]) => {
|
||||
fetch(`/api/monthly_summary_trend?area_name=${encodeURIComponent(area)}`)
|
||||
const params = buildFilterParams({ areaOverride: area });
|
||||
fetch(`/api/monthly_summary_trend?${params.toString()}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.status === 'success') renderExcelChart(chart, tbody, d.trend); })
|
||||
.then(d => { if (d.status === 'success') renderExcelChart(chart, tbody, d.trend, d.period); })
|
||||
.catch(err => console.error('monthly special trend failed', err));
|
||||
});
|
||||
}
|
||||
@@ -102,13 +144,42 @@
|
||||
// ── 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:'所有類別' };
|
||||
const defaultMap = { year:'最新年份', month:'全部月份', area:'所有區域', vendor:'所有廠商', trade:'所有類別' };
|
||||
$(`#${btnMap[type]}`).text(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)');
|
||||
@@ -130,10 +201,31 @@
|
||||
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(labels.year);
|
||||
$('#btnMonth').text(labels.month);
|
||||
$('#btnArea').text(labels.area);
|
||||
$('#btnVendor').text(labels.vendor);
|
||||
$('#btnTrade').text(labels.trade);
|
||||
}
|
||||
|
||||
// ── UI updates ────────────────────────────────────────
|
||||
function updateUI(data) {
|
||||
const period = data.period || {};
|
||||
$('#activeReportPeriod').text(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);
|
||||
|
||||
@@ -169,16 +261,16 @@
|
||||
});
|
||||
table.draw();
|
||||
|
||||
renderExcelChart(compareChart, 'compareChartTableBody', data.trend);
|
||||
renderExcelChart(compareChart, 'compareChartTableBody', data.trend, period);
|
||||
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);
|
||||
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);
|
||||
renderAreaRankingChart(areaRankingChart, 'areaRankingChartTableBody', data.area_ranking);
|
||||
renderSeasonalityHeatmap(seasonalityHeatmapChart, data.heatmap_data, period);
|
||||
renderAreaRankingChart(areaRankingChart, 'areaRankingChartTableBody', data.area_ranking, period);
|
||||
}
|
||||
|
||||
// ── Highlights (Top 3) ────────────────────────────────
|
||||
@@ -195,22 +287,34 @@
|
||||
}
|
||||
|
||||
// ── Excel-style 主對比圖 ─────────────────────────────
|
||||
function renderExcelChart(chart, tableBodyId, trend) {
|
||||
function renderExcelChart(chart, tableBodyId, trend, period = {}) {
|
||||
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);
|
||||
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('/');
|
||||
if (y === currYear) currData[parseInt(m) - 1] = t.sales;
|
||||
if (y === prevYear) prevData[parseInt(m) - 1] = t.sales;
|
||||
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 < 12; i++) {
|
||||
for (let i = 0; i < months.length; i++) {
|
||||
if (prevData[i] > 0) yoyData[i] = parseFloat(((currData[i] - prevData[i]) / prevData[i] * 100).toFixed(1));
|
||||
}
|
||||
|
||||
@@ -220,14 +324,14 @@
|
||||
|
||||
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"><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: [currYear, prevYear, 'YOY'], bottom: 0, show: false },
|
||||
legend: { data: [currentSeriesLabel, previousSeriesLabel, 'YOY'], bottom: 0, show: false },
|
||||
grid: {
|
||||
left: compact ? '42' : '80',
|
||||
right: compact ? '16' : '50',
|
||||
@@ -245,9 +349,9 @@
|
||||
{ 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,
|
||||
{ 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: prevYear, type: 'bar', data: prevData, barMaxWidth: compact ? 12 : 20,
|
||||
{ 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,
|
||||
@@ -286,56 +390,63 @@
|
||||
}
|
||||
|
||||
// ── 類別分佈 (雙圓餅) ─────────────────────────────────
|
||||
function renderDivisionDistChart(chart, _tableId, data) {
|
||||
function renderDivisionDistChart(chart, _tableId, data, period = {}) {
|
||||
if (!data || !data.length) return;
|
||||
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: 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' } }
|
||||
{ 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>${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'),
|
||||
{ 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: '2025 年', type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['75%', compact ? '53%' : '55%'], data: pie('sales_2025'),
|
||||
{ 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) {
|
||||
function renderPriceRangeChart(chart, tableId, data, period = {}) {
|
||||
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);
|
||||
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 => ps[0].name + '<br/>' + ps.map(p => {
|
||||
const v = p.value || 0;
|
||||
const total = p.seriesName.includes('2024') ? t24 : t25;
|
||||
const total = p.seriesName === previousLabel ? previousTotal : currentTotal;
|
||||
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' } },
|
||||
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: ['2024','2025'].map((y) => ({
|
||||
name: y + ' 銷售', type: 'bar', barWidth: compact ? 13 : 30,
|
||||
data: data.map(d => d['sales_' + y] || 0),
|
||||
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 = y === '2024' ? t24 : t25;
|
||||
const total = series.total;
|
||||
const pct = total > 0 ? (v/total*100).toFixed(1) + '%' : '';
|
||||
return pct + '\n' + (v/10000).toFixed(0) + '萬';
|
||||
}
|
||||
@@ -397,21 +508,23 @@
|
||||
}
|
||||
|
||||
// ── 廠商排行 ──────────────────────────────────────────
|
||||
function renderVendorRankingChart(chart, tableId, data) {
|
||||
function renderVendorRankingChart(chart, tableId, data, period = {}) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const cd = data.slice().reverse();
|
||||
const previousLabel = period.previous_label || '去年同期';
|
||||
const currentLabel = period.label || '本期';
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: ['2024 銷售額', '2025 銷售額'], bottom: 0, show: !compact, textStyle: { fontSize: 15, fontWeight: 'bold' } },
|
||||
legend: { data: [previousLabel, currentLabel], 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%',
|
||||
{ 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: '2025 銷售額', type: 'bar', data: cd.map(d => d.sales_2025 || 0), barWidth: compact ? 10 : 18,
|
||||
{ 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) + '萬' : '' } }
|
||||
]
|
||||
@@ -419,33 +532,34 @@
|
||||
|
||||
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">${previousLabel}</th><th class="text-end">${currentLabel}</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>`;
|
||||
<th class="text-end">${previousLabel}毛利</th><th class="text-end">${currentLabel}毛利</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' : '');
|
||||
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;">${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 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">${p24.toLocaleString()}</td>
|
||||
<td class="text-end text-success fw-bold">${p25.toLocaleString()}</td></tr>`;
|
||||
<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) {
|
||||
function renderSeasonalityHeatmap(chart, data, _period = {}) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||||
@@ -453,7 +567,8 @@
|
||||
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 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} ${d.category}`);
|
||||
@@ -475,19 +590,24 @@
|
||||
}
|
||||
|
||||
// ── 區域排行 ──────────────────────────────────────────
|
||||
function renderAreaRankingChart(chart, tableId, data) {
|
||||
function renderAreaRankingChart(chart, tableId, data, period = {}) {
|
||||
if (!data || !data.length) return;
|
||||
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 => 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 } },
|
||||
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 => 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),
|
||||
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) + '萬' }
|
||||
}))
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user