/* =========================================================
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 = `尚無可繪製資料${message}`;
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();
});
})();