Files
ewoooc/web/static/js/page-daily-sales.js
OoO 81f4a0d18a
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
perf: 延後載入業績圖表資源
2026-05-18 08:51:09 +08:00

420 lines
13 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 || []
};
// -- 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;
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;
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;
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;
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));
});
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 (global) ----------------------------------
window.changeDate = function () {
const sel = document.getElementById('dateSelector');
if (!sel) return;
window.location.href = `/daily_sales?date=${sel.value}`;
};
window.toggleDateSelection = function (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}`;
}
};
window.backToMonthView = function () {
const params = new URLSearchParams(window.location.search);
const month = params.get('month');
window.location.href = month
? `/daily_sales?month=${month}`
: '/daily_sales';
};
window.changeMonth = function (month) {
window.location.href = `/daily_sales?month=${month}`;
};
// -- Export handlers -----------------------------------------------
window.exportCategoryTable = function () {
const sel = document.getElementById('dateSelector');
if (!sel || !sel.value) { alert('請先選擇日期'); return; }
window.location.href = `/daily_sales/export?date=${sel.value}`;
};
window.exportMarketingData = function () {
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;
};
// -- 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);
}
function bootCharts() {
loadChartJs()
.then(renderAllCharts)
.catch(error => console.error('[daily_sales] Chart.js 載入失敗:', error));
}
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 () {
initDataTable();
observeCharts();
});
})();