420 lines
13 KiB
JavaScript
420 lines
13 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;
|
||
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();
|
||
});
|
||
})();
|