Files
ewoooc/web/static/js/page-daily-sales.js
ogt 911393190d
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(analytics): enforce trustworthy chart rendering
2026-07-16 15:55:22 +08:00

1081 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =========================================================
page-daily-sales.js
Chart.js 多圖控制 + 行事曆/日期切換邏輯
配色從 CSS var 讀取analysis-chart-theme.js 已注入 Chart.defaults
========================================================= */
(function () {
'use strict';
function readDailySalesData() {
const node = document.getElementById('daily-sales-data');
if (!node && window.__DAILY_SALES__) return window.__DAILY_SALES__;
if (!node) return null;
const rawPayload = [
node.content && node.content.textContent,
node.textContent,
node.innerHTML
].find(value => value && value.trim());
if (!rawPayload) {
console.error('[daily_sales] chart data is empty');
return null;
}
try {
return JSON.parse(rawPayload);
} catch (error) {
console.error('[daily_sales] chart data parse failed:', error);
return null;
}
}
const dailySalesData = readDailySalesData();
if (!dailySalesData) return;
window.__DAILY_SALES__ = dailySalesData;
let chartsRendered = false;
const isCompact = () =>
window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
// -- Palette 讀自 CSS variable (analysis-chart-theme.js 公開) ----------
function cssVar(name, fallback) {
const v = getComputedStyle(document.documentElement)
.getPropertyValue(name).trim();
return v || fallback;
}
function rgba(hex, alpha) {
if (!hex) return `rgba(0,0,0,${alpha})`;
let c = hex.replace('#', '');
if (c.length === 3) c = c.split('').map(x => x + x).join('');
const num = parseInt(c, 16);
if (Number.isNaN(num)) return hex;
const r = (num >> 16) & 255, g = (num >> 8) & 255, b = num & 255;
return `rgba(${r},${g},${b},${alpha})`;
}
const palette = {
caramel: cssVar('--momo-page-accent', '#c96442'),
honey: cssVar('--momo-tag-honey', '#b88416'),
rust: cssVar('--momo-tag-rust', '#b5342f'),
mahogany: cssVar('--momo-tag-mahogany', '#8f4530'),
olive: cssVar('--momo-tag-olive', '#6f7a3a'),
clay: cssVar('--momo-tag-clay', '#a0613a'),
muted: cssVar('--momo-text-muted', '#7e6f5c')
};
function loadChartJs() {
if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) {
return window.EwoooCChartTheme.loadChartJs();
}
if (window.Chart) return Promise.resolve(window.Chart);
return Promise.reject(new Error('Chart.js loader unavailable'));
}
function applyChartDefaults() {
Chart.defaults.color = palette.muted;
Chart.defaults.borderColor = rgba(palette.muted, 0.18);
Chart.defaults.font.family = "'Noto Sans TC', 'Inter', system-ui, sans-serif";
}
// -- Safe data extraction ---------------------------------------------
const cd = dailySalesData.chartData || {};
const competitor = dailySalesData.competitor || {};
const competitorTrend = competitor.trend || {};
const competitorCoverage = competitor.coverage || {};
const categoryChart = dailySalesData.categoryChart || {};
const safe = {
labels: cd.labels || [],
revenue: cd.revenue || [],
profit: cd.profit || [],
margin_rate: cd.margin_rate || (cd.revenue || []).map((revenue, index) => {
const rev = Number(revenue || 0);
if (!rev) return 0;
return Number((cd.profit || [])[index] || 0) / rev * 100;
}),
avg_price: cd.avg_price || [],
qty: cd.qty || [],
dod_revenue: cd.dod_revenue || [],
dod_profit: cd.dod_profit || [],
dod_avg_price: cd.dod_avg_price || [],
dod_qty: cd.dod_qty || [],
wow_revenue: cd.wow_revenue || [],
wow_profit: cd.wow_profit || [],
wow_avg_price: cd.wow_avg_price || [],
wow_qty: cd.wow_qty || [],
top10_labels: cd.top10_labels || [],
top10_values: cd.top10_values || []
};
const chartInstances = [];
function rememberChart(chart) {
if (chart) chartInstances.push(chart);
return chart;
}
function stabilizeCharts() {
window.requestAnimationFrame(() => {
chartInstances.forEach(chart => {
if (!chart) return;
if (typeof chart.resize === 'function') chart.resize();
if (typeof chart.update === 'function') chart.update('none');
});
window.dispatchEvent(new Event('resize'));
});
}
function formatShort(value, mode) {
const n = Number(value || 0);
if (mode === 'pct') return `${n.toFixed(1)}%`;
if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`;
return Math.round(n).toLocaleString();
}
function formatMetric(value, mode) {
const n = Number(value || 0);
if (mode === 'pct') return `${n >= 0 ? '+' : ''}${n.toFixed(1)}%`;
if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`;
return Math.round(n).toLocaleString();
}
function axisMoney(title) {
return {
beginAtZero: true,
grace: '8%',
title: { display: !isCompact(), text: title },
ticks: { callback: value => formatMetric(value, 'currency') }
};
}
function axisPercent(title) {
return {
beginAtZero: true,
grace: '12%',
title: { display: !isCompact(), text: title },
ticks: { callback: value => formatMetric(value, 'pct') },
grid: {
color: context => Number(context.tick.value) === 0
? rgba(palette.muted, 0.38)
: rgba(palette.muted, 0.12),
lineWidth: context => Number(context.tick.value) === 0 ? 1.2 : 1
}
};
}
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;
if (!wrap) return;
wrap.classList.add('chart-fallback-active');
canvas.setAttribute('aria-hidden', 'true');
if (wrap.querySelector('.chart-fallback-list, .chart-fallback-bars')) return;
const pairs = (labels || []).map((label, index) => ({
label: String(label || ''),
value: Number((values || [])[index] || 0)
})).filter(item => Number.isFinite(item.value));
const data = options.limit ? pairs.slice(-options.limit) : pairs;
if (!data.length) return;
const max = Math.max(...data.map(item => Math.abs(item.value)), 1);
const chart = document.createElement('div');
chart.className = `chart-fallback-bars ${options.horizontal ? 'is-horizontal' : 'is-vertical'}`;
data.forEach(item => {
const bar = document.createElement('div');
bar.className = `chart-fallback-bar ${item.value < 0 ? 'is-negative' : ''}`;
const pct = Math.max(4, Math.round(Math.abs(item.value) / max * 100));
if (options.horizontal) {
bar.style.setProperty('--bar-w', `${pct}%`);
} else {
bar.style.setProperty('--bar-h', `${pct}%`);
}
const label = document.createElement('span');
label.className = 'chart-fallback-label';
label.textContent = item.label.length > 10 ? `${item.label.slice(0, 10)}...` : item.label;
const value = document.createElement('strong');
value.className = 'chart-fallback-value';
value.textContent = formatShort(item.value, options.mode);
bar.append(label, value);
chart.appendChild(bar);
});
wrap.appendChild(chart);
}
function renderHtmlChartFallbacks() {
renderHtmlBars('trendChart', safe.labels, safe.revenue, { mode: 'currency', limit: 14 });
renderHtmlBars('dodChart', safe.labels, safe.dod_revenue, { mode: 'pct', limit: 14 });
renderHtmlBars('wowChart', safe.labels, safe.wow_revenue, { mode: 'pct', limit: 14 });
renderHtmlBars('top10Chart', safe.top10_labels, safe.top10_values, {
mode: 'currency',
horizontal: true
});
const mk = dailySalesData.marketing || {};
if (mk.discount) renderHtmlBars('discountChart', mk.discount.labels, mk.discount.values, {
mode: 'currency',
horizontal: true
});
if (mk.coupon) renderHtmlBars('couponChart', mk.coupon.labels, mk.coupon.values, {
mode: 'currency',
horizontal: true
});
if (competitorTrend.labels) {
renderHtmlBars('competitorGapChart', competitorTrend.labels, competitorTrend.avg_gap_pct, {
mode: 'pct',
limit: 14
});
}
renderHtmlBars('marginChart', safe.labels, safe.margin_rate, { mode: 'pct', limit: 14 });
renderHtmlBars('avgQtyChart', safe.labels, safe.avg_price, { mode: 'currency', limit: 14 });
if (categoryChart.labels) {
renderHtmlBars('categoryRevenueChart', categoryChart.labels, categoryChart.revenue, {
mode: 'currency',
horizontal: true
});
}
const coverage = buildCoverageFunnel();
renderHtmlBars('competitorCoverageChart', coverage.labels, coverage.values, {
mode: 'number',
horizontal: true
});
}
function hasSeriesData(labels, ...seriesList) {
return Array.isArray(labels) &&
labels.length > 0 &&
seriesList.some(series =>
Array.isArray(series) &&
series.some(value => Number.isFinite(Number(value)) && Math.abs(Number(value)) > 1e-9)
);
}
function renderChartEmpty(canvasId, message) {
const canvas = document.getElementById(canvasId);
const wrap = canvas ? canvas.closest('.chart-container') : null;
if (!wrap || wrap.querySelector('.chart-empty-state')) return;
wrap.classList.add('chart-empty-active');
canvas.setAttribute('aria-hidden', 'true');
const empty = document.createElement('div');
empty.className = 'chart-empty-state';
empty.innerHTML = `<strong>尚無可繪製資料</strong><span>${message}</span>`;
wrap.appendChild(empty);
}
// -- Helpers ----------------------------------------------------------
function makeLineDataset(label, data, color, yAxisID) {
return {
label, data,
borderColor: color,
backgroundColor: rgba(color, 0.14),
borderWidth: 2.4,
tension: 0.34,
cubicInterpolationMode: 'monotone',
fill: false,
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
};
}
function makeWowDataset(label, data, color) {
const muted = palette.muted;
return {
...makeLineDataset(label, data, color, undefined),
segment: {
borderColor: ctx => ctx.p0DataIndex < 7 ? rgba(muted, 0.6) : color
}
};
}
function buildCoverageFunnel() {
return {
labels: ['決策支援', '精準告警', '身份配對', '待刷新', '單位價', '待覆核'],
values: [
competitorCoverage.decision_support_count ?? competitorCoverage.decision_ready_count ?? 0,
competitorCoverage.decision_ready_matches ?? competitorCoverage.fresh_matches ?? 0,
competitorCoverage.valid_matches ?? 0,
competitorCoverage.stale_matches ?? competitorCoverage.stale_match_count ?? 0,
competitorCoverage.unit_comparable_count ?? 0,
competitorCoverage.rescore_accepted_count ?? competitorCoverage.review_queue_count ?? 0
].map(value => Number(value || 0))
};
}
// -- Chart 1: trend (multi-line) --------------------------------------
function renderTrend() {
const el = document.getElementById('trendChart');
if (!el) return;
if (!hasSeriesData(safe.labels, safe.revenue, safe.profit, safe.avg_price, safe.qty)) {
renderChartEmpty('trendChart', '目前圖表 payload 沒有日別業績序列,請確認匯入資料與快取狀態。');
return;
}
rememberChart(new Chart(el, {
type: 'line',
data: {
labels: safe.labels,
datasets: [
makeLineDataset('業績', safe.revenue, palette.caramel, 'y'),
makeLineDataset('毛利', safe.profit, palette.honey, 'y'),
makeLineDataset('客單價', safe.avg_price, palette.mahogany, 'y1'),
makeLineDataset('銷量', safe.qty, palette.olive, 'y2')
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: isCompact() ? 'bottom' : 'top' },
tooltip: {
callbacks: {
label: ctx => {
const metric = ctx.dataset.yAxisID === 'y2' ? 'number' : 'currency';
return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, metric)}`;
}
}
}
},
scales: {
x: dateAxis(el),
y: { type: 'linear', position: 'left', ...axisMoney('業績 / 毛利') },
y1: {
type: 'linear', position: 'right', ...axisMoney('客單價'),
grid: { drawOnChartArea: false },
},
y2: {
type: 'linear', position: 'right', display: false, beginAtZero: true,
grid: { drawOnChartArea: false }
}
}
}
}));
}
// -- Chart 2: DoD (multi-line %) --------------------------------------
function renderDod() {
const el = document.getElementById('dodChart');
if (!el) return;
if (!hasSeriesData(safe.labels, safe.dod_revenue, safe.dod_profit, safe.dod_avg_price, safe.dod_qty)) {
renderChartEmpty('dodChart', '目前沒有 Day-over-Day 成長率序列。');
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%', 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: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: isCompact() ? 'bottom' : 'top' },
tooltip: {
callbacks: {
label: ctx => `${ctx.dataset.label}: ${percentTooltipValue(ctx)}`
}
}
},
scales: {
x: dateAxis(el),
y: axisPercent('DoD 成長率')
}
}
}));
}
// -- Chart 3: WoW (multi-line %, 前 7 天淡灰) -------------------------
function renderWow() {
const el = document.getElementById('wowChart');
if (!el) return;
if (!hasSeriesData(safe.labels, safe.wow_revenue, safe.wow_profit, safe.wow_avg_price, safe.wow_qty)) {
renderChartEmpty('wowChart', '目前沒有 Week-over-Week 成長率序列。');
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%', 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: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: isCompact() ? 'bottom' : 'top' },
tooltip: {
callbacks: {
label: ctx => {
const v = ctx.parsed.y;
const i = ctx.dataIndex;
if (i < 7 || v === 0) {
return `${ctx.dataset.label}: 無對比資料(需上週同日數據)`;
}
return `${ctx.dataset.label}: ${percentTooltipValue(ctx)}`;
}
}
}
},
scales: {
x: dateAxis(el),
y: axisPercent('WoW 成長率')
}
}
}));
}
// -- Chart 4: Top 10 (橫向 bar) ---------------------------------------
function renderTop10() {
const el = document.getElementById('top10Chart');
if (!el) return;
if (!hasSeriesData(safe.top10_labels, safe.top10_values)) {
renderChartEmpty('top10Chart', '所選日期沒有可排序的商品業績資料。');
return;
}
rememberChart(new Chart(el, {
type: 'bar',
data: {
labels: safe.top10_labels,
datasets: [{
label: '銷售金額',
data: safe.top10_values,
backgroundColor: rgba(palette.caramel, 0.62),
borderColor: palette.caramel,
borderWidth: 1,
maxBarThickness: 22
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.x, 'currency') } }
},
scales: {
x: axisMoney('銷售金額'),
y: { grid: { display: false }, ticks: { autoSkip: false } }
}
}
}));
}
// -- Chart 5: Margin rate trend --------------------------------------
function renderMarginRate() {
const el = document.getElementById('marginChart');
if (!el) return;
if (!hasSeriesData(safe.labels, safe.margin_rate)) {
renderChartEmpty('marginChart', '目前沒有可計算的毛利率序列。');
return;
}
rememberChart(new Chart(el, {
type: 'line',
data: {
labels: safe.labels,
datasets: [
makeLineDataset('毛利率', safe.margin_rate, palette.olive),
{
label: '30 日均線',
data: safe.margin_rate.map((_, index, values) => {
const start = Math.max(0, index - 6);
const sample = values.slice(start, index + 1).map(Number).filter(Number.isFinite);
return sample.length ? sample.reduce((sum, value) => sum + value, 0) / sample.length : 0;
}),
borderColor: palette.honey,
backgroundColor: rgba(palette.honey, 0.1),
borderWidth: 1.8,
borderDash: [5, 5],
tension: 0.28,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: isCompact() ? 'bottom' : 'top' },
tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, 'pct')}` } }
},
scales: {
x: dateAxis(el),
y: axisPercent('毛利率')
}
}
}));
}
// -- Chart 6: AOV x Qty ----------------------------------------------
function renderAvgQty() {
const el = document.getElementById('avgQtyChart');
if (!el) return;
if (!hasSeriesData(safe.labels, safe.avg_price, safe.qty)) {
renderChartEmpty('avgQtyChart', '目前沒有客單價或銷量序列。');
return;
}
rememberChart(new Chart(el, {
type: 'bar',
data: {
labels: safe.labels,
datasets: [
{
label: '銷量',
data: safe.qty,
backgroundColor: rgba(palette.olive, 0.24),
borderColor: palette.olive,
borderWidth: 1,
maxBarThickness: 24,
yAxisID: 'y'
},
{
label: '客單價',
data: safe.avg_price,
type: 'line',
borderColor: palette.mahogany,
backgroundColor: rgba(palette.mahogany, 0.12),
borderWidth: 2.2,
tension: 0.32,
pointRadius: 2,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: isCompact() ? 'bottom' : 'top' },
tooltip: {
callbacks: {
label: ctx => {
const mode = ctx.dataset.yAxisID === 'y1' ? 'currency' : 'number';
return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, mode)}`;
}
}
}
},
scales: {
x: dateAxis(el),
y: { beginAtZero: true, title: { display: !isCompact(), text: '銷量' } },
y1: {
position: 'right',
grid: { drawOnChartArea: false },
...axisMoney('客單價')
}
}
}
}));
}
// -- Chart 7: Category revenue ---------------------------------------
function renderCategoryRevenue() {
const el = document.getElementById('categoryRevenueChart');
if (!el) return;
if (!hasSeriesData(categoryChart.labels, categoryChart.revenue, categoryChart.profit)) {
renderChartEmpty('categoryRevenueChart', '目前沒有分類業績彙總可繪製。');
return;
}
rememberChart(new Chart(el, {
type: 'bar',
data: {
labels: categoryChart.labels || [],
datasets: [
{
label: '業績',
data: categoryChart.revenue || [],
backgroundColor: rgba(palette.caramel, 0.5),
borderColor: palette.caramel,
borderWidth: 1,
maxBarThickness: 22
},
{
label: '毛利',
data: categoryChart.profit || [],
backgroundColor: rgba(palette.olive, 0.42),
borderColor: palette.olive,
borderWidth: 1,
maxBarThickness: 22
}
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: isCompact() ? 'bottom' : 'top' },
tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.x, 'currency')}` } }
},
scales: {
x: axisMoney('金額'),
y: {
grid: { display: false },
ticks: {
autoSkip: false,
callback: function (value) {
const label = this.getLabelForValue(value);
return label.length > 14 ? `${label.slice(0, 14)}` : label;
}
}
}
}
}
}));
}
// -- Chart 8: Competitor gap pressure --------------------------------
function renderCompetitorGap() {
const el = document.getElementById('competitorGapChart');
if (!el) return;
const labels = competitorTrend.labels || [];
const avgGap = competitorTrend.avg_gap_pct || [];
const riskCount = competitorTrend.risk_count || [];
if (!hasSeriesData(labels, avgGap, riskCount)) {
renderChartEmpty('competitorGapChart', '目前沒有高信心 PChome 歷史價差序列。');
return;
}
rememberChart(new Chart(el, {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'MOMO 較 PChome 貴 SKU 數',
data: riskCount,
backgroundColor: rgba(palette.rust, 0.28),
borderColor: palette.rust,
borderWidth: 1,
maxBarThickness: 24,
yAxisID: 'y'
},
{
label: '平均價差 %',
data: avgGap,
type: 'line',
borderColor: palette.caramel,
backgroundColor: rgba(palette.caramel, 0.12),
borderWidth: 2.2,
tension: 0.32,
pointRadius: 2,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: isCompact() ? 'bottom' : 'top' },
tooltip: {
callbacks: {
label: ctx => {
const mode = ctx.dataset.yAxisID === 'y1' ? 'pct' : 'number';
return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, mode)}`;
}
}
}
},
scales: {
x: dateAxis(el),
y: { beginAtZero: true, title: { display: !isCompact(), text: '風險 SKU 數' } },
y1: {
position: 'right',
grid: { drawOnChartArea: false },
...axisPercent('平均價差')
}
}
}
}));
}
// -- Chart 9: Competitor decision coverage ---------------------------
function renderCompetitorCoverage() {
const el = document.getElementById('competitorCoverageChart');
if (!el) return;
const coverage = buildCoverageFunnel();
if (!hasSeriesData(coverage.labels, coverage.values)) {
renderChartEmpty('competitorCoverageChart', '目前尚未形成可繪製的比價覆蓋資料。');
return;
}
rememberChart(new Chart(el, {
type: 'bar',
data: {
labels: coverage.labels,
datasets: [{
label: 'SKU 數',
data: coverage.values,
backgroundColor: [
rgba(palette.caramel, 0.62),
rgba(palette.olive, 0.54),
rgba(palette.honey, 0.54),
rgba(palette.rust, 0.38),
rgba(palette.mahogany, 0.32),
rgba(palette.muted, 0.24)
],
borderColor: [
palette.caramel,
palette.olive,
palette.honey,
palette.rust,
palette.mahogany,
palette.muted
],
borderWidth: 1,
maxBarThickness: 18
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.x, 'number')}` } }
},
scales: {
x: { beginAtZero: true, ticks: { precision: 0 } },
y: { grid: { display: false } }
}
}
}));
}
// -- Marketing charts ----------------------------------------------
function renderMarketingBar(elId, marketing, color) {
const el = document.getElementById(elId);
if (!el || !marketing) return;
if (!hasSeriesData(marketing.labels, marketing.values)) {
renderChartEmpty(elId, '所選期間沒有對應的行銷活動業績資料。');
return;
}
const shades = Array.from({ length: marketing.labels.length }, (_, i) => {
const a = 0.8 - i * 0.05;
return rgba(color, Math.max(a, 0.25));
});
rememberChart(new Chart(el.getContext('2d'), {
type: 'bar',
data: {
labels: marketing.labels,
datasets: [{
label: '業績',
data: marketing.values,
backgroundColor: shades,
borderColor: color,
borderWidth: 1,
maxBarThickness: 22
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: ctx => formatMetric(ctx.parsed.x, 'currency') }
}
},
scales: {
x: axisMoney('業績'),
y: {
grid: { display: false },
ticks: {
autoSkip: false,
font: { size: 11 },
callback: function (v) {
const lbl = this.getLabelForValue(v);
return lbl.length > 25 ? lbl.substr(0, 25) + '…' : lbl;
}
}
}
},
onClick: () => exportMarketingData()
}
}));
}
// -- DataTables init ------------------------------------------------
function initDataTable() {
if (typeof $ === 'undefined' || !$.fn.DataTable) return;
$('#categoryTable').DataTable({
order: [[2, 'desc']],
pageLength: 25,
language: typeof window.EwoooCDataTableLanguage === 'function'
? window.EwoooCDataTableLanguage()
: {}
});
}
// -- Navigation handlers -------------------------------------------
function changeDate() {
const sel = document.getElementById('dateSelector');
if (!sel) return;
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}&month=${clickedDate.slice(0, 7)}`;
return;
}
if (clickedDate === currentSelectedDate) {
window.backToMonthView();
} else {
window.location.href = `/daily_sales?date=${clickedDate}&month=${clickedDate.slice(0, 7)}`;
}
}
function backToMonthView() {
const params = new URLSearchParams(window.location.search);
const month = params.get('month');
window.location.href = month
? `/daily_sales?month=${month}`
: '/daily_sales';
}
function changeMonth(month) {
window.location.href = `/daily_sales?month=${month}`;
}
// -- Export handlers -----------------------------------------------
function exportCategoryTable() {
const sel = document.getElementById('dateSelector');
if (!sel || !sel.value) { alert('請先選擇日期'); return; }
window.location.href = `/daily_sales/export?date=${sel.value}`;
}
function exportMarketingData() {
const sel = document.getElementById('dateSelector');
const date = sel ? sel.value : '';
const params = new URLSearchParams(window.location.search);
const isMonthView = !params.has('date');
let url = '/daily_sales/export_marketing?type=all';
if (isMonthView) {
const month = params.get('month') || new Date().toISOString().slice(0, 7);
const [y, m] = month.split('-');
const start = `${y}-${m}-01`;
const end = new Date(y, m, 0).toISOString().slice(0, 10);
url += `&start_date=${start}&end_date=${end}`;
} else {
url += `&date=${date}`;
}
window.location.href = url;
}
function initDailySalesActions() {
const page = document.querySelector('.daily-sales-page');
const selectedDate = page ? page.dataset.selectedDate : '';
document.querySelectorAll('[data-daily-date-selector]').forEach(select => {
select.addEventListener('change', changeDate);
});
document.querySelectorAll('[data-daily-action="month-view"]').forEach(control => {
control.addEventListener('click', event => {
event.preventDefault();
backToMonthView();
});
control.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
backToMonthView();
});
});
document.querySelectorAll('[data-daily-month]').forEach(button => {
button.addEventListener('click', () => changeMonth(button.dataset.dailyMonth));
});
document.querySelectorAll('.cal-day[data-has-data="true"][data-date]').forEach(day => {
day.addEventListener('click', () => toggleDateSelection(day.dataset.date, selectedDate));
day.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleDateSelection(day.dataset.date, selectedDate);
});
day.setAttribute('role', 'button');
day.setAttribute('tabindex', '0');
});
document.querySelectorAll('[data-daily-export="marketing"]').forEach(button => {
button.addEventListener('click', exportMarketingData);
});
document.querySelectorAll('[data-daily-export="category"]').forEach(button => {
button.addEventListener('click', exportCategoryTable);
});
}
// -- Boot -----------------------------------------------------------
function renderAllCharts() {
if (chartsRendered) return;
chartsRendered = true;
applyChartDefaults();
renderTrend();
renderDod();
renderWow();
renderTop10();
renderMarginRate();
renderAvgQty();
renderCategoryRevenue();
renderCompetitorGap();
renderCompetitorCoverage();
const mk = dailySalesData.marketing || {};
if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel);
if (mk.coupon) renderMarketingBar('couponChart', mk.coupon, palette.olive);
stabilizeCharts();
}
function bootCharts() {
document.documentElement.dataset.dailyCharts = 'loading';
loadChartJs()
.then(() => {
renderAllCharts();
document.documentElement.dataset.dailyCharts = 'ready';
})
.catch(error => {
document.documentElement.dataset.dailyCharts = 'error';
document.documentElement.dataset.dailyChartsError = error && error.message ? error.message : String(error);
console.error('[daily_sales] Chart.js 載入失敗:', error);
renderHtmlChartFallbacks();
});
}
function scheduleChartBoot() {
window.setTimeout(bootCharts, 0);
}
function observeCharts() {
const targets = Array.from(document.querySelectorAll('.chart-card'));
if (!targets.length) return;
if (!('IntersectionObserver' in window)) {
bootCharts();
return;
}
const observer = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) return;
observer.disconnect();
bootCharts();
}, { rootMargin: '260px 0px' });
targets.forEach(target => observer.observe(target));
}
document.addEventListener('DOMContentLoaded', function () {
initDailySalesActions();
initDataTable();
scheduleChartBoot();
});
})();