996 lines
33 KiB
JavaScript
996 lines
33 KiB
JavaScript
/* =========================================================
|
||
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: false,
|
||
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 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 => context.dataIndex === context.dataset.data.length - 1 ? 3 : 1.6,
|
||
pointHoverRadius: 5,
|
||
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: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||
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;
|
||
}
|
||
|
||
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)
|
||
]
|
||
},
|
||
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: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||
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;
|
||
}
|
||
|
||
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)
|
||
]
|
||
},
|
||
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}: ${formatMetric(v, 'pct')}`;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||
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: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||
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: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||
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: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||
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: {
|
||
url: 'https://cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json'
|
||
}
|
||
});
|
||
}
|
||
|
||
// -- Navigation handlers -------------------------------------------
|
||
function changeDate() {
|
||
const sel = document.getElementById('dateSelector');
|
||
if (!sel) return;
|
||
window.location.href = `/daily_sales?date=${sel.value}`;
|
||
}
|
||
|
||
function toggleDateSelection(clickedDate, currentSelectedDate) {
|
||
const isMonthView = window.__DAILY_SALES__.isMonthView;
|
||
if (isMonthView) {
|
||
window.location.href = `/daily_sales?date=${clickedDate}`;
|
||
return;
|
||
}
|
||
if (clickedDate === currentSelectedDate) {
|
||
window.backToMonthView();
|
||
} else {
|
||
window.location.href = `/daily_sales?date=${clickedDate}`;
|
||
}
|
||
}
|
||
|
||
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();
|
||
});
|
||
})();
|