fix(analytics): enforce trustworthy chart rendering
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:
@@ -1018,6 +1018,14 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.chart-scale-note {
|
||||
margin: 8px 0 0;
|
||||
color: var(--momo-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chart-fallback-bars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -183,6 +183,15 @@
|
||||
font-size: var(--momo-text-body-sm, 13px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ms-period-empty--error {
|
||||
border-left-color: var(--momo-danger-text, #8f4530);
|
||||
background: var(--momo-danger-bg, #f6e4dc);
|
||||
}
|
||||
|
||||
.ms-period-empty--error i {
|
||||
color: var(--momo-danger-text, #8f4530);
|
||||
}
|
||||
.ms-filter-group__label {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-family: var(--momo-font-display);
|
||||
@@ -472,7 +481,7 @@
|
||||
}
|
||||
|
||||
#vendorRankingChart {
|
||||
height: 620px !important;
|
||||
height: 460px !important;
|
||||
}
|
||||
|
||||
#divisionDistChart,
|
||||
|
||||
@@ -429,6 +429,38 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sa-chart-shell > canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sa-chart-shell.chart-empty-active > canvas {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sa-chart-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 6px;
|
||||
padding: 20px;
|
||||
color: var(--momo-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sa-chart-empty strong {
|
||||
color: var(--momo-text-strong);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sa-chart-empty span {
|
||||
max-width: 38rem;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- Vendor table ---------- */
|
||||
.sa-vendor-table { max-height: 560px; overflow-y: auto; }
|
||||
.sa-vendor-table__profit {
|
||||
|
||||
@@ -122,11 +122,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof ds.backgroundColor !== 'function') {
|
||||
if (typeof ds.backgroundColor !== 'function' && !Array.isArray(ds.backgroundColor)) {
|
||||
ds.backgroundColor =
|
||||
t === 'line' ? alpha(color, ds.fill ? 0.18 : 0.0) : alpha(color, 0.78);
|
||||
}
|
||||
if (typeof ds.borderColor !== 'function') ds.borderColor = color;
|
||||
if (typeof ds.borderColor !== 'function' && !Array.isArray(ds.borderColor)) ds.borderColor = color;
|
||||
|
||||
ds.borderWidth = ds.borderWidth || (t === 'line' ? 2 : 1);
|
||||
ds.borderRadius = ds.borderRadius ?? (t === 'bar' ? 3 : undefined);
|
||||
@@ -195,28 +195,28 @@
|
||||
|
||||
const scales = config.options.scales || {};
|
||||
Object.keys(scales).forEach(k => {
|
||||
const originalScale = scales[k];
|
||||
scales[k] = {
|
||||
border: { color: theme.faint, ...(scales[k].border || {}) },
|
||||
...originalScale,
|
||||
border: { color: theme.faint, ...(originalScale.border || {}) },
|
||||
grid: {
|
||||
color: theme.faint,
|
||||
tickColor: 'transparent',
|
||||
drawBorder: false,
|
||||
...(scales[k].grid || {})
|
||||
...(originalScale.grid || {})
|
||||
},
|
||||
ticks: {
|
||||
color: theme.muted,
|
||||
maxRotation: isCompact() ? 0 : 30,
|
||||
autoSkip: isCompact(),
|
||||
autoSkip: true,
|
||||
maxTicksLimit: isCompact() ? 5 : 8,
|
||||
font: {
|
||||
family: theme.mono,
|
||||
size: isCompact() ? 10 : 11,
|
||||
weight: '500'
|
||||
},
|
||||
callback: v => formatNumber(v),
|
||||
...(scales[k].ticks || {})
|
||||
},
|
||||
...scales[k]
|
||||
...(originalScale.ticks || {})
|
||||
}
|
||||
};
|
||||
});
|
||||
config.options.scales = scales;
|
||||
@@ -313,6 +313,7 @@
|
||||
const tuneAxis = axis => {
|
||||
if (!axis || typeof axis !== 'object') return axis;
|
||||
return {
|
||||
...axis,
|
||||
axisLine: { lineStyle: { color: theme.faint }, ...(axis.axisLine || {}) },
|
||||
axisTick: { lineStyle: { color: theme.faint }, ...(axis.axisTick || {}) },
|
||||
axisLabel: {
|
||||
@@ -324,8 +325,7 @@
|
||||
splitLine: {
|
||||
lineStyle: { color: theme.faint, type: 'dashed' },
|
||||
...(axis.splitLine || {})
|
||||
},
|
||||
...axis
|
||||
}
|
||||
};
|
||||
};
|
||||
const tuneAxisList = axes => {
|
||||
|
||||
31
web/static/js/datatables-zh-hant.js
Normal file
31
web/static/js/datatables-zh-hant.js
Normal file
@@ -0,0 +1,31 @@
|
||||
(function () {
|
||||
const language = {
|
||||
processing: '處理中...',
|
||||
loadingRecords: '載入中...',
|
||||
lengthMenu: '每頁顯示 _MENU_ 筆',
|
||||
zeroRecords: '沒有符合條件的資料',
|
||||
emptyTable: '目前沒有可顯示的資料',
|
||||
info: '顯示第 _START_ 至 _END_ 筆,共 _TOTAL_ 筆',
|
||||
infoEmpty: '顯示第 0 至 0 筆,共 0 筆',
|
||||
infoFiltered: '(從 _MAX_ 筆資料中篩選)',
|
||||
search: '搜尋:',
|
||||
paginate: {
|
||||
first: '第一頁',
|
||||
previous: '上一頁',
|
||||
next: '下一頁',
|
||||
last: '最後一頁'
|
||||
},
|
||||
aria: {
|
||||
sortAscending: ':升冪排列',
|
||||
sortDescending: ':降冪排列'
|
||||
}
|
||||
};
|
||||
|
||||
window.EwoooCDataTableLanguage = function () {
|
||||
return {
|
||||
...language,
|
||||
paginate: { ...language.paginate },
|
||||
aria: { ...language.aria }
|
||||
};
|
||||
};
|
||||
})();
|
||||
@@ -145,7 +145,7 @@
|
||||
|
||||
function axisPercent(title) {
|
||||
return {
|
||||
beginAtZero: false,
|
||||
beginAtZero: true,
|
||||
grace: '12%',
|
||||
title: { display: !isCompact(), text: title },
|
||||
ticks: { callback: value => formatMetric(value, 'pct') },
|
||||
@@ -158,6 +158,75 @@
|
||||
};
|
||||
}
|
||||
|
||||
function dateAxis(element) {
|
||||
const shell = element && element.closest('.chart-container');
|
||||
const width = shell ? shell.getBoundingClientRect().width : window.innerWidth;
|
||||
const maxTicksLimit = width < 440 ? 4 : width < 640 ? 6 : isCompact() ? 5 : 10;
|
||||
return {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxTicksLimit,
|
||||
minRotation: 0,
|
||||
maxRotation: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildBoundedPercentSeries(seriesList) {
|
||||
const absoluteValues = seriesList
|
||||
.flatMap(series => Array.isArray(series) ? series : [])
|
||||
.map(Number)
|
||||
.filter(value => Number.isFinite(value) && Math.abs(value) > 1e-9)
|
||||
.map(Math.abs)
|
||||
.sort((a, b) => a - b);
|
||||
const quantileIndex = Math.floor(Math.max(0, absoluteValues.length - 1) * 0.9);
|
||||
const robustExtent = absoluteValues[quantileIndex] || 0;
|
||||
const limit = Math.min(100, Math.max(20, Math.ceil(robustExtent / 10) * 10 || 20));
|
||||
let clippedCount = 0;
|
||||
const series = seriesList.map(values => (Array.isArray(values) ? values : []).map((value, index) => {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return null;
|
||||
const originalValue = Number(value);
|
||||
const clipped = Math.abs(originalValue) > limit;
|
||||
if (clipped) clippedCount += 1;
|
||||
return {
|
||||
x: safe.labels[index],
|
||||
y: clipped ? Math.sign(originalValue) * limit : originalValue,
|
||||
originalValue,
|
||||
clipped
|
||||
};
|
||||
}));
|
||||
return { series, limit, clippedCount };
|
||||
}
|
||||
|
||||
function renderPercentScaleNote(canvasId, bounded) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const wrap = canvas ? canvas.closest('.chart-container') : null;
|
||||
const host = wrap ? wrap.parentElement : null;
|
||||
if (!host) return;
|
||||
let note = host.querySelector(`.chart-scale-note[data-for="${canvasId}"]`);
|
||||
if (!bounded.clippedCount) {
|
||||
if (note) note.remove();
|
||||
return;
|
||||
}
|
||||
if (!note) {
|
||||
note = document.createElement('p');
|
||||
note.className = 'chart-scale-note';
|
||||
note.dataset.for = canvasId;
|
||||
host.appendChild(note);
|
||||
}
|
||||
note.textContent = `${bounded.clippedCount} 個極端值以 ±${bounded.limit}% 邊界標記;完整值保留於提示。`;
|
||||
}
|
||||
|
||||
function percentTooltipValue(context) {
|
||||
const raw = context.raw;
|
||||
const original = raw && typeof raw === 'object' && Number.isFinite(Number(raw.originalValue))
|
||||
? Number(raw.originalValue)
|
||||
: context.parsed.y;
|
||||
const suffix = raw && raw.clipped ? '(圖上邊界)' : '';
|
||||
return `${formatMetric(original, 'pct')}${suffix}`;
|
||||
}
|
||||
|
||||
function renderHtmlBars(canvasId, labels, values, options = {}) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const wrap = canvas ? canvas.closest('.chart-container') : null;
|
||||
@@ -267,8 +336,14 @@
|
||||
tension: 0.34,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
fill: false,
|
||||
pointRadius: context => context.dataIndex === context.dataset.data.length - 1 ? 3 : 1.6,
|
||||
pointHoverRadius: 5,
|
||||
pointRadius: context => {
|
||||
if (context.raw && context.raw.clipped) return 5;
|
||||
if (context.dataset.data.length === 1) return 6;
|
||||
return context.dataIndex === context.dataset.data.length - 1 ? 3 : 1.6;
|
||||
},
|
||||
pointStyle: context => context.raw && context.raw.clipped ? 'triangle' : 'circle',
|
||||
pointRotation: context => context.raw && context.raw.clipped && Number(context.raw.originalValue) < 0 ? 180 : 0,
|
||||
pointHoverRadius: 6,
|
||||
pointHitRadius: 12,
|
||||
yAxisID
|
||||
};
|
||||
@@ -334,7 +409,7 @@
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
x: dateAxis(el),
|
||||
y: { type: 'linear', position: 'left', ...axisMoney('業績 / 毛利') },
|
||||
y1: {
|
||||
type: 'linear', position: 'right', ...axisMoney('客單價'),
|
||||
@@ -358,15 +433,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const bounded = buildBoundedPercentSeries([
|
||||
safe.dod_revenue, safe.dod_profit, safe.dod_avg_price, safe.dod_qty
|
||||
]);
|
||||
renderPercentScaleNote('dodChart', bounded);
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: safe.labels,
|
||||
datasets: [
|
||||
makeLineDataset('業績 DoD%', safe.dod_revenue, palette.caramel),
|
||||
makeLineDataset('毛利 DoD%', safe.dod_profit, palette.honey),
|
||||
makeLineDataset('客單 DoD%', safe.dod_avg_price, palette.mahogany),
|
||||
makeLineDataset('銷量 DoD%', safe.dod_qty, palette.olive)
|
||||
makeLineDataset('業績 DoD%', bounded.series[0], palette.caramel),
|
||||
makeLineDataset('毛利 DoD%', bounded.series[1], palette.honey),
|
||||
makeLineDataset('客單 DoD%', bounded.series[2], palette.mahogany),
|
||||
makeLineDataset('銷量 DoD%', bounded.series[3], palette.olive)
|
||||
]
|
||||
},
|
||||
options: {
|
||||
@@ -377,12 +457,12 @@
|
||||
legend: { position: isCompact() ? 'bottom' : 'top' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, 'pct')}`
|
||||
label: ctx => `${ctx.dataset.label}: ${percentTooltipValue(ctx)}`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
x: dateAxis(el),
|
||||
y: axisPercent('DoD 成長率')
|
||||
}
|
||||
}
|
||||
@@ -398,15 +478,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const bounded = buildBoundedPercentSeries([
|
||||
safe.wow_revenue, safe.wow_profit, safe.wow_avg_price, safe.wow_qty
|
||||
]);
|
||||
renderPercentScaleNote('wowChart', bounded);
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: safe.labels,
|
||||
datasets: [
|
||||
makeWowDataset('業績 WoW%', safe.wow_revenue, palette.caramel),
|
||||
makeWowDataset('毛利 WoW%', safe.wow_profit, palette.honey),
|
||||
makeWowDataset('客單 WoW%', safe.wow_avg_price, palette.mahogany),
|
||||
makeWowDataset('銷量 WoW%', safe.wow_qty, palette.olive)
|
||||
makeWowDataset('業績 WoW%', bounded.series[0], palette.caramel),
|
||||
makeWowDataset('毛利 WoW%', bounded.series[1], palette.honey),
|
||||
makeWowDataset('客單 WoW%', bounded.series[2], palette.mahogany),
|
||||
makeWowDataset('銷量 WoW%', bounded.series[3], palette.olive)
|
||||
]
|
||||
},
|
||||
options: {
|
||||
@@ -423,13 +508,13 @@
|
||||
if (i < 7 || v === 0) {
|
||||
return `${ctx.dataset.label}: 無對比資料(需上週同日數據)`;
|
||||
}
|
||||
return `${ctx.dataset.label}: ${formatMetric(v, 'pct')}`;
|
||||
return `${ctx.dataset.label}: ${percentTooltipValue(ctx)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
x: dateAxis(el),
|
||||
y: axisPercent('WoW 成長率')
|
||||
}
|
||||
}
|
||||
@@ -514,7 +599,7 @@
|
||||
tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, 'pct')}` } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
x: dateAxis(el),
|
||||
y: axisPercent('毛利率')
|
||||
}
|
||||
}
|
||||
@@ -573,7 +658,7 @@
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
x: dateAxis(el),
|
||||
y: { beginAtZero: true, title: { display: !isCompact(), text: '銷量' } },
|
||||
y1: {
|
||||
position: 'right',
|
||||
@@ -697,7 +782,7 @@
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
x: dateAxis(el),
|
||||
y: { beginAtZero: true, title: { display: !isCompact(), text: '風險 SKU 數' } },
|
||||
y1: {
|
||||
position: 'right',
|
||||
@@ -823,9 +908,9 @@
|
||||
$('#categoryTable').DataTable({
|
||||
order: [[2, 'desc']],
|
||||
pageLength: 25,
|
||||
language: {
|
||||
url: 'https://cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json'
|
||||
}
|
||||
language: typeof window.EwoooCDataTableLanguage === 'function'
|
||||
? window.EwoooCDataTableLanguage()
|
||||
: {}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -87,13 +87,22 @@
|
||||
|
||||
function pctAxis(title) {
|
||||
return {
|
||||
beginAtZero: false,
|
||||
beginAtZero: true,
|
||||
grace: '12%',
|
||||
title: { display: !isCompact(), text: title },
|
||||
ticks: { callback: value => formatMetric(value, 'pct') }
|
||||
};
|
||||
}
|
||||
|
||||
function isSinglePoint(series) {
|
||||
return Array.isArray(series) &&
|
||||
series.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))).length === 1;
|
||||
}
|
||||
|
||||
function pointRadiusFor(series) {
|
||||
return isSinglePoint(series) ? 6 : 2;
|
||||
}
|
||||
|
||||
function renderHtmlBars(canvasId, labels, values, options = {}) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const wrap = canvas ? canvas.closest('.ga-chart-card__body') : null;
|
||||
@@ -203,8 +212,8 @@
|
||||
order: 1,
|
||||
tension: 0.34,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
pointRadius: pointRadiusFor(data.yoy),
|
||||
pointHoverRadius: 6
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -224,7 +233,7 @@
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } },
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 }, offset: data.labels.length === 1 },
|
||||
y: moneyAxis('月營收'),
|
||||
y1: {
|
||||
position: 'right',
|
||||
@@ -260,7 +269,7 @@
|
||||
tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.y, 'pct') } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } },
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 }, offset: data.labels.length === 1 },
|
||||
y: pctAxis('MoM 月增率')
|
||||
}
|
||||
}
|
||||
@@ -282,8 +291,8 @@
|
||||
fill: true,
|
||||
tension: 0.36,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
pointRadius: pointRadiusFor(data.aov),
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
@@ -295,7 +304,7 @@
|
||||
tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.y, 'currency') } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } },
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 }, offset: data.labels.length === 1 },
|
||||
y: moneyAxis('平均單價')
|
||||
}
|
||||
}
|
||||
@@ -317,8 +326,8 @@
|
||||
fill: true,
|
||||
tension: 0.36,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
pointRadius: pointRadiusFor(data.margin_rate),
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
@@ -330,7 +339,7 @@
|
||||
tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.y, 'pct') } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } },
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 }, offset: data.labels.length === 1 },
|
||||
y: pctAxis('毛利率')
|
||||
}
|
||||
}
|
||||
@@ -364,8 +373,8 @@
|
||||
yAxisID: 'y1',
|
||||
tension: 0.34,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
pointRadius: pointRadiusFor(data.competitor_gap_pct),
|
||||
pointHoverRadius: 6
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -385,7 +394,7 @@
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } },
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 }, offset: data.labels.length === 1 },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: !isCompact(), text: '風險 SKU 數' }
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
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 = {
|
||||
@@ -32,6 +37,22 @@
|
||||
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'));
|
||||
@@ -48,7 +69,9 @@
|
||||
privacyInfantChart = echarts.init(document.getElementById('privacyInfantChart'));
|
||||
|
||||
table = $('#summaryTable').DataTable({
|
||||
language: { url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' },
|
||||
language: typeof window.EwoooCDataTableLanguage === 'function'
|
||||
? window.EwoooCDataTableLanguage()
|
||||
: {},
|
||||
autoWidth: false,
|
||||
deferRender: true,
|
||||
pageLength: 20,
|
||||
@@ -65,7 +88,12 @@
|
||||
specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart]
|
||||
.forEach(c => c && c.resize());
|
||||
});
|
||||
const refreshForViewport = () => fetchData();
|
||||
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) {
|
||||
@@ -109,36 +137,98 @@
|
||||
});
|
||||
}
|
||||
|
||||
function fetchData() {
|
||||
$('#loadingOverlay').css('display', 'flex');
|
||||
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);
|
||||
updateAnalysisTabLinks(data.period);
|
||||
}
|
||||
$('#loadingOverlay').hide();
|
||||
}).catch(err => { console.error(err); $('#loadingOverlay').hide(); });
|
||||
|
||||
fetchSpecialChartData();
|
||||
function setReportError(message = '') {
|
||||
const state = document.getElementById('monthlyReportErrorState');
|
||||
if (!state) return;
|
||||
state.hidden = !message;
|
||||
const detail = document.getElementById('monthlyReportErrorMessage');
|
||||
if (detail) detail.textContent = message;
|
||||
}
|
||||
|
||||
function fetchSpecialChartData() {
|
||||
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']
|
||||
];
|
||||
pairs.forEach(([area, chart, tbody]) => {
|
||||
await Promise.all(pairs.map(async ([area, chart, tbody]) => {
|
||||
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, d.period); })
|
||||
.catch(err => console.error('monthly special trend failed', err));
|
||||
});
|
||||
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 ────────────────────────────────────────────
|
||||
@@ -150,7 +240,7 @@
|
||||
}
|
||||
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]);
|
||||
$(`#${btnMap[type]}`).text(decodeText(value || defaultMap[type]));
|
||||
syncFilterUrl();
|
||||
fetchData();
|
||||
}
|
||||
@@ -194,7 +284,19 @@
|
||||
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>`));
|
||||
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');
|
||||
@@ -208,11 +310,11 @@
|
||||
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);
|
||||
$('#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 ────────────────────────────────────────
|
||||
@@ -259,10 +361,10 @@
|
||||
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>`,
|
||||
`${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
|
||||
]);
|
||||
});
|
||||
@@ -305,7 +407,7 @@
|
||||
$('#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>`));
|
||||
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, '$');
|
||||
@@ -348,9 +450,9 @@
|
||||
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 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>
|
||||
@@ -433,13 +535,13 @@
|
||||
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);
|
||||
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>${p.name}</strong><br/>業績: ${(p.value/10000).toFixed(0)}萬<br/>佔比: ${p.percent.toFixed(1)}%` },
|
||||
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'),
|
||||
@@ -467,11 +569,11 @@
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' },
|
||||
formatter: ps => ps[0].name + '<br/>' + ps.map(p => {
|
||||
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} ${p.seriesName}: ${v.toLocaleString()} (${pct})`;
|
||||
return `${p.marker} ${safeHtmlText(p.seriesName)}: ${v.toLocaleString()} (${pct})`;
|
||||
}).join('<br/>')
|
||||
},
|
||||
legend: { data: [previousLabel, currentLabel], bottom: 0, show: !compact, textStyle: { fontSize: 13, fontWeight: 'bold' } },
|
||||
@@ -506,18 +608,18 @@
|
||||
}
|
||||
chart.clear();
|
||||
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)';
|
||||
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">${p.value[3]}</div><div>毛利率: ${p.value[0]}%</div><div>銷售額: $${p.value[1].toLocaleString()}</div><div>銷量: ${p.value[2].toLocaleString()}</div>` },
|
||||
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, d.name]),
|
||||
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 => {
|
||||
@@ -546,10 +648,10 @@
|
||||
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];
|
||||
return [avg, d.qty, d.sales, decodeText(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>` },
|
||||
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' } } },
|
||||
@@ -566,14 +668,22 @@
|
||||
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 ? '18' : '110', top: compact ? '10' : '20', bottom: compact ? '28' : '45' },
|
||||
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 => d.name), axisLabel: { width: compact ? 78 : 150, overflow: 'truncate', interval: 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,
|
||||
@@ -598,7 +708,7 @@
|
||||
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-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>
|
||||
@@ -622,20 +732,23 @@
|
||||
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; });
|
||||
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} ${d.category}`);
|
||||
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>${yLabels[p.value[1]]}</strong><br/>${months[p.value[0]]}<br/>業績: <strong>${(p.value[2]/10000).toFixed(0)}萬</strong>` },
|
||||
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' } },
|
||||
@@ -660,10 +773,10 @@
|
||||
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/>') },
|
||||
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 => d.name), axisLabel: { interval: 0, rotate: compact ? 28 : 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
|
||||
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' },
|
||||
@@ -674,7 +787,7 @@
|
||||
}))
|
||||
});
|
||||
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>`; });
|
||||
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>`);
|
||||
}
|
||||
|
||||
@@ -131,8 +131,55 @@
|
||||
const baseGrid = { color: mix(INK, 8), drawBorder: false };
|
||||
const baseTicks = { color: MUTED, font: { family: getComputedStyle(document.documentElement).fontFamily } };
|
||||
|
||||
const hasDrawableValues = (values) => Array.isArray(values) && values.some((value) => {
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(item => Number.isFinite(Number(item)) && Math.abs(Number(item)) > 1e-9);
|
||||
}
|
||||
return Number.isFinite(Number(value)) && Math.abs(Number(value)) > 1e-9;
|
||||
});
|
||||
|
||||
const chartValues = (series) => {
|
||||
if (!series || typeof series !== 'object') return [];
|
||||
if (Array.isArray(series.values) && series.values.length) return series.values;
|
||||
return Array.isArray(series.chart_values) ? series.chart_values : [];
|
||||
};
|
||||
|
||||
const renderChartEmpty = (canvas, message) => {
|
||||
if (!canvas) return null;
|
||||
const shell = canvas.closest('.sa-chart-shell');
|
||||
if (!shell) return null;
|
||||
shell.classList.add('chart-empty-active');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
let empty = shell.querySelector('.sa-chart-empty');
|
||||
if (!empty) {
|
||||
empty = document.createElement('div');
|
||||
empty.className = 'sa-chart-empty';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = '尚無可繪製資料';
|
||||
const detail = document.createElement('span');
|
||||
empty.append(title, detail);
|
||||
shell.appendChild(empty);
|
||||
}
|
||||
empty.querySelector('span').textContent = message;
|
||||
return null;
|
||||
};
|
||||
|
||||
const prepareChartCanvas = (canvas) => {
|
||||
if (!canvas) return;
|
||||
const shell = canvas.closest('.sa-chart-shell');
|
||||
if (shell) {
|
||||
shell.classList.remove('chart-empty-active');
|
||||
shell.querySelector('.sa-chart-empty')?.remove();
|
||||
}
|
||||
canvas.removeAttribute('aria-hidden');
|
||||
};
|
||||
|
||||
const horizontalBar = (canvas, labels, values, label, fmt) => {
|
||||
if (!canvas || !labels || !labels.length) return null;
|
||||
if (!canvas) return null;
|
||||
if (!Array.isArray(labels) || !labels.length || !hasDrawableValues(values)) {
|
||||
return renderChartEmpty(canvas, '所選期間沒有可比較的排行資料。');
|
||||
}
|
||||
prepareChartCanvas(canvas);
|
||||
return new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
@@ -152,14 +199,27 @@
|
||||
},
|
||||
scales: {
|
||||
x: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() } },
|
||||
y: { grid: { display: false }, ticks: baseTicks }
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
...baseTicks,
|
||||
callback(value) {
|
||||
const text = String(this.getLabelForValue(value) || '');
|
||||
return text.length > 28 ? `${text.slice(0, 28)}...` : text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const doughnut = (canvas, labels, values) => {
|
||||
if (!canvas || !labels || !labels.length) return null;
|
||||
if (!canvas) return null;
|
||||
if (!Array.isArray(labels) || !labels.length || !hasDrawableValues(values)) {
|
||||
return renderChartEmpty(canvas, '所選期間沒有可計算的類別占比。');
|
||||
}
|
||||
prepareChartCanvas(canvas);
|
||||
return new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
@@ -188,7 +248,12 @@
|
||||
};
|
||||
|
||||
const lineTrend = (canvas, labels, values, label, fmt) => {
|
||||
if (!canvas || !labels || !labels.length) return null;
|
||||
if (!canvas) return null;
|
||||
if (!Array.isArray(labels) || !labels.length || !hasDrawableValues(values)) {
|
||||
return renderChartEmpty(canvas, '所選期間沒有可繪製的趨勢資料。');
|
||||
}
|
||||
prepareChartCanvas(canvas);
|
||||
const singlePoint = labels.length === 1;
|
||||
return new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -197,7 +262,7 @@
|
||||
borderColor: ACCENT,
|
||||
backgroundColor: mix(ACCENT, 14),
|
||||
tension: 0.34, fill: true, pointBackgroundColor: ACCENT,
|
||||
pointRadius: 3, pointHoverRadius: 5, borderWidth: 2.5
|
||||
pointRadius: singlePoint ? 6 : 3, pointHoverRadius: 6, borderWidth: 2.5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
@@ -207,7 +272,7 @@
|
||||
tooltip: { callbacks: { label: (c) => fmt ? fmt(c.parsed.y) : c.parsed.y.toLocaleString() } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: baseTicks },
|
||||
x: { grid: { display: false }, ticks: baseTicks, offset: singlePoint },
|
||||
y: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() }, beginAtZero: true }
|
||||
}
|
||||
}
|
||||
@@ -215,7 +280,11 @@
|
||||
};
|
||||
|
||||
const verticalBar = (canvas, labels, values, label, fmt) => {
|
||||
if (!canvas || !labels || !labels.length) return null;
|
||||
if (!canvas) return null;
|
||||
if (!Array.isArray(labels) || !labels.length || !hasDrawableValues(values)) {
|
||||
return renderChartEmpty(canvas, '所選期間沒有可繪製的分布資料。');
|
||||
}
|
||||
prepareChartCanvas(canvas);
|
||||
return new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
@@ -230,7 +299,7 @@
|
||||
plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => fmt ? fmt(c.parsed.y) : c.parsed.y.toLocaleString() } } },
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: baseTicks },
|
||||
y: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() } }
|
||||
y: { beginAtZero: true, grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() } }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -274,7 +343,7 @@
|
||||
if (data.barData) {
|
||||
horizontalBar(
|
||||
document.getElementById('barChart'),
|
||||
data.barData.labels, data.barData.values,
|
||||
data.barData.labels, chartValues(data.barData),
|
||||
data.barData.label || '銷售',
|
||||
data.barData.is_money ? fmtMoney : fmtNum
|
||||
);
|
||||
@@ -283,12 +352,13 @@
|
||||
// 2. Category doughnut
|
||||
if (data.categoryData) {
|
||||
doughnut(document.getElementById('categoryChart'),
|
||||
data.categoryData.labels, data.categoryData.values);
|
||||
data.categoryData.labels, chartValues(data.categoryData));
|
||||
}
|
||||
|
||||
// 3. Treemap
|
||||
const treemapEl = document.getElementById('treemapChart');
|
||||
if (treemapEl && data.treemapData && data.treemapData.length) {
|
||||
prepareChartCanvas(treemapEl);
|
||||
new Chart(treemapEl, {
|
||||
type: 'treemap',
|
||||
data: {
|
||||
@@ -317,18 +387,21 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
renderChartEmpty(treemapEl, '所選期間沒有可建立板塊分布的商品業績。');
|
||||
}
|
||||
|
||||
// 4. Price distribution (vertical bar)
|
||||
if (data.priceDistData) {
|
||||
verticalBar(document.getElementById('priceDistChart'),
|
||||
data.priceDistData.labels, data.priceDistData.values,
|
||||
data.priceDistData.labels, chartValues(data.priceDistData),
|
||||
'業績', fmtMoney);
|
||||
}
|
||||
|
||||
// 5. Scatter
|
||||
const scEl = document.getElementById('scatterChart');
|
||||
if (scEl && data.scatterData && data.scatterData.length) {
|
||||
prepareChartCanvas(scEl);
|
||||
new Chart(scEl, {
|
||||
type: 'scatter',
|
||||
data: {
|
||||
@@ -351,11 +424,14 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
renderChartEmpty(scEl, '所選期間沒有同時具備價格與銷量的商品資料。');
|
||||
}
|
||||
|
||||
// 6. BCG matrix
|
||||
const bcgEl = document.getElementById('bcgChart');
|
||||
if (bcgEl && data.bcgData && data.bcgData.points && data.bcgData.points.length) {
|
||||
prepareChartCanvas(bcgEl);
|
||||
const pts = data.bcgData.points;
|
||||
const xMid = data.bcgData.x_median || 0;
|
||||
const yMid = data.bcgData.y_median || 0;
|
||||
@@ -385,11 +461,15 @@
|
||||
},
|
||||
plugins: [bcgQuadrants(xMid, yMid)]
|
||||
});
|
||||
} else {
|
||||
renderChartEmpty(bcgEl, '所選期間缺少可計算毛利率與銷量的商品資料。');
|
||||
}
|
||||
|
||||
// 7. Seasonality heatmap (matrix-style via grouped bars stacked – fallback)
|
||||
const seasEl = document.getElementById('seasonalityChart');
|
||||
if (seasEl && data.seasonalityData && data.seasonalityData.matrix) {
|
||||
const seasonalityMatrix = data.seasonalityData && data.seasonalityData.matrix;
|
||||
if (seasEl && Array.isArray(seasonalityMatrix) && seasonalityMatrix.some(hasDrawableValues)) {
|
||||
prepareChartCanvas(seasEl);
|
||||
const cats = data.seasonalityData.categories;
|
||||
const months = data.seasonalityData.months;
|
||||
// build datasets: one per category
|
||||
@@ -411,43 +491,56 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
renderChartEmpty(seasEl, '至少需要兩個月份的分類業績才能判讀淡旺季。');
|
||||
}
|
||||
|
||||
// 8. Marketing — discount + coupon
|
||||
const discountCanvas = document.getElementById('mktDiscountChart');
|
||||
const couponCanvas = document.getElementById('mktCouponChart');
|
||||
if (data.marketingData) {
|
||||
if (data.marketingData.discount) {
|
||||
horizontalBar(document.getElementById('mktDiscountChart'),
|
||||
horizontalBar(discountCanvas,
|
||||
data.marketingData.discount.labels, data.marketingData.discount.values,
|
||||
'折扣業績', fmtMoney);
|
||||
} else {
|
||||
renderChartEmpty(discountCanvas, '所選期間沒有折扣活動業績。');
|
||||
}
|
||||
if (data.marketingData.coupon) {
|
||||
horizontalBar(document.getElementById('mktCouponChart'),
|
||||
horizontalBar(couponCanvas,
|
||||
data.marketingData.coupon.labels, data.marketingData.coupon.values,
|
||||
'折價券業績', fmtMoney);
|
||||
} else {
|
||||
renderChartEmpty(couponCanvas, '所選期間沒有折價券活動業績。');
|
||||
}
|
||||
} else {
|
||||
renderChartEmpty(discountCanvas, '所選期間沒有折扣活動業績。');
|
||||
renderChartEmpty(couponCanvas, '所選期間沒有折價券活動業績。');
|
||||
}
|
||||
|
||||
// 9. Time-dimension trends
|
||||
if (data.monthlyData) {
|
||||
lineTrend(document.getElementById('monthlyChart'),
|
||||
data.monthlyData.labels, data.monthlyData.values, '月業績', fmtMoney);
|
||||
data.monthlyData.labels, chartValues(data.monthlyData), '月業績', fmtMoney);
|
||||
}
|
||||
if (data.weeklyData) {
|
||||
lineTrend(document.getElementById('weeklyChart'),
|
||||
data.weeklyData.labels, data.weeklyData.values, '週業績', fmtMoney);
|
||||
data.weeklyData.labels, chartValues(data.weeklyData), '週業績', fmtMoney);
|
||||
}
|
||||
if (data.dowData) {
|
||||
verticalBar(document.getElementById('dowChart'),
|
||||
data.dowData.labels, data.dowData.values, '日業績', fmtMoney);
|
||||
data.dowData.labels, chartValues(data.dowData), '日業績', fmtMoney);
|
||||
}
|
||||
if (data.hourlyData) {
|
||||
verticalBar(document.getElementById('hourlyChart'),
|
||||
data.hourlyData.labels, data.hourlyData.values, '時業績', fmtMoney);
|
||||
data.hourlyData.labels, chartValues(data.hourlyData), '時業績', fmtMoney);
|
||||
}
|
||||
|
||||
// 10. Heatmap (DOW × Hour) — stacked bar fallback
|
||||
const hmEl = document.getElementById('heatmapChart');
|
||||
if (hmEl && data.heatmapData && data.heatmapData.matrix) {
|
||||
const heatmapMatrix = data.heatmapData && data.heatmapData.matrix;
|
||||
if (hmEl && Array.isArray(heatmapMatrix) && heatmapMatrix.some(hasDrawableValues)) {
|
||||
prepareChartCanvas(hmEl);
|
||||
const dows = data.heatmapData.dows || ['週一','週二','週三','週四','週五','週六','週日'];
|
||||
const hours = data.heatmapData.hours || Array.from({length: 24}, (_, i) => `${i}:00`);
|
||||
const datasets = hours.map((h, i) => ({
|
||||
@@ -468,6 +561,8 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
renderChartEmpty(hmEl, '所選期間沒有可交叉分析的星期與小時資料。');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -504,6 +599,11 @@
|
||||
|
||||
const ctx = document.getElementById('yoy-chart');
|
||||
if (yoyChart) yoyChart.destroy();
|
||||
if (!breakdown.length || (!hasDrawableValues(breakdown.map(row => row.year1_value)) && !hasDrawableValues(breakdown.map(row => row.year2_value)))) {
|
||||
renderChartEmpty(ctx, '所選期間與比較年度沒有可繪製的月別資料。');
|
||||
return;
|
||||
}
|
||||
prepareChartCanvas(ctx);
|
||||
yoyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -525,7 +625,10 @@
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(e => console.warn('[yoy] fetch failed', e));
|
||||
.catch(e => {
|
||||
console.warn('[yoy] fetch failed', e);
|
||||
renderChartEmpty(document.getElementById('yoy-chart'), '年度比較資料暫時無法載入。');
|
||||
});
|
||||
};
|
||||
|
||||
// ─── DataTable ─────────────────────────────────────────
|
||||
@@ -557,25 +660,35 @@
|
||||
processing: true, serverSide: false,
|
||||
pageLength: 25, lengthMenu: [10, 25, 50, 100],
|
||||
order: [[$tbl.find('thead th').length - 1, 'desc']],
|
||||
language: { url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' },
|
||||
language: typeof window.EwoooCDataTableLanguage === 'function'
|
||||
? window.EwoooCDataTableLanguage()
|
||||
: {},
|
||||
columnDefs: [{ targets: '_all', defaultContent: '—' }]
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Boot ──────────────────────────────────────────────
|
||||
const boot = () => {
|
||||
initFlatpickr();
|
||||
const data = readData();
|
||||
if (data) buildCharts(data);
|
||||
initDataTable();
|
||||
if (typeof window.loadYoYData === 'function' && document.getElementById('yoy-chart')) {
|
||||
try { window.loadYoYData(); } catch (e) { /* silent */ }
|
||||
document.documentElement.dataset.salesCharts = 'loading';
|
||||
try {
|
||||
initFlatpickr();
|
||||
const data = readData();
|
||||
if (data) buildCharts(data);
|
||||
initDataTable();
|
||||
if (typeof window.loadYoYData === 'function' && document.getElementById('yoy-chart')) {
|
||||
window.loadYoYData();
|
||||
}
|
||||
if (window.bootstrap) {
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
|
||||
}
|
||||
document.documentElement.dataset.salesCharts = 'ready';
|
||||
} catch (error) {
|
||||
document.documentElement.dataset.salesCharts = 'error';
|
||||
document.documentElement.dataset.salesChartsError = error && error.message ? error.message : String(error);
|
||||
console.error('[sales-analysis] chart boot failed:', error);
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
// tooltips
|
||||
if (window.bootstrap) {
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
|
||||
}
|
||||
hideLoading();
|
||||
};
|
||||
|
||||
// Show loading on form submit
|
||||
|
||||
Reference in New Issue
Block a user