Files
ewoooc/web/static/js/page-daily-sales.js
OoO c420d48263
Some checks failed
CD Pipeline / deploy (push) Failing after 26s
Fix PPT auto generation and analytics fallbacks
2026-05-18 11:52:31 +08:00

560 lines
18 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;
try {
return JSON.parse(node.textContent || '{}');
} 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;
// -- 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 safe = {
labels: cd.labels || [],
revenue: cd.revenue || [],
profit: cd.profit || [],
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 renderHtmlBars(canvasId, labels, values, options = {}) {
const canvas = document.getElementById(canvasId);
const wrap = canvas ? canvas.closest('.chart-container') : null;
if (!wrap || wrap.querySelector('.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;
wrap.classList.add('has-html-chart');
canvas.setAttribute('aria-hidden', 'true');
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
});
}
// -- Helpers ----------------------------------------------------------
function makeLineDataset(label, data, color, yAxisID) {
return {
label, data,
borderColor: color,
backgroundColor: rgba(color, 0.14),
borderWidth: 2,
tension: 0.3,
fill: false,
pointRadius: 2,
pointHoverRadius: 5,
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
}
};
}
// -- Chart 1: trend (multi-line) --------------------------------------
function renderTrend() {
const el = document.getElementById('trendChart');
if (!el || !safe.labels.length) 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: 'top' } },
scales: {
y: {
type: 'linear', position: 'left', beginAtZero: true,
title: { display: true, text: '業績/毛利 ($)', color: palette.caramel }
},
y1: {
type: 'linear', position: 'right', beginAtZero: true,
grid: { drawOnChartArea: false },
title: { display: true, text: '客單價 ($)', color: palette.mahogany }
},
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 || !safe.labels.length) 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: 'top' },
tooltip: {
callbacks: {
label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)}%`
}
}
},
scales: {
y: { beginAtZero: false, title: { display: true, text: 'DoD 成長率 (%)' } }
}
}
}));
}
// -- Chart 3: WoW (multi-line %, 前 7 天淡灰) -------------------------
function renderWow() {
const el = document.getElementById('wowChart');
if (!el || !safe.labels.length) 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: '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}: ${v.toFixed(1)}%`;
}
}
}
},
scales: {
y: { beginAtZero: false, title: { display: true, text: 'WoW 成長率 (%)' } }
}
}
}));
}
// -- Chart 4: Top 10 (橫向 bar) ---------------------------------------
function renderTop10() {
const el = document.getElementById('top10Chart');
if (!el || !safe.top10_labels.length) 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
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true } }
}
}));
}
// -- Marketing charts ----------------------------------------------
function renderMarketingBar(elId, marketing, color) {
const el = document.getElementById(elId);
if (!el || !marketing) 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
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: ctx => '$' + ctx.raw.toLocaleString() }
}
},
scales: {
x: {
beginAtZero: true,
ticks: { callback: v => '$' + v.toLocaleString() }
},
y: {
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();
const mk = dailySalesData.marketing || {};
if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel);
if (mk.coupon) renderMarketingBar('couponChart', mk.coupon, palette.olive);
stabilizeCharts();
renderHtmlChartFallbacks();
}
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);
});
}
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();
});
})();