Files
ewoooc/web/static/js/page-daily-sales.js
OoO 1dd4181fab
All checks were successful
CD Pipeline / deploy (push) Successful in 1m17s
V10.585 提升比價覆蓋工作台與每日業績圖表
2026-06-04 19:12:34 +08:00

996 lines
33 KiB
JavaScript
Raw Permalink 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: 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();
});
})();