551 lines
22 KiB
JavaScript
551 lines
22 KiB
JavaScript
/* =========================================================
|
||
page-sales-analysis.js
|
||
業績分析頁邏輯(v3)
|
||
依賴:jQuery / Bootstrap5 / Chart.js 3.9 / chartjs-chart-treemap / DataTables / Flatpickr
|
||
analysis-chart-theme.js ← 全站暖色 palette + Chart.defaults
|
||
========================================================= */
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ─── Token helpers ──────────────────────────────────────
|
||
const css = (name, fallback) => {
|
||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||
return v || fallback;
|
||
};
|
||
const PALETTE = (window.EwoooCChart && window.EwoooCChart.palette) || [
|
||
css('--momo-page-accent', '#C77B4E'),
|
||
css('--momo-tag-olive', '#7A8C3F'),
|
||
css('--momo-tag-mahogany', '#9B4D3F'),
|
||
css('--momo-tag-honey', '#C39A3D'),
|
||
css('--momo-tag-walnut', '#7A5C44'),
|
||
css('--momo-tag-rust', '#B85C3A'),
|
||
css('--momo-tag-caramel', '#D49464')
|
||
];
|
||
const INK = css('--momo-text-strong', '#2A2520');
|
||
const MUTED = css('--momo-text-muted', '#6F635A');
|
||
const BORDER = css('--momo-border-subtle', '#E2D7C9');
|
||
const SURFACE = css('--momo-surface', '#FAF6EE');
|
||
const ACCENT = PALETTE[0];
|
||
|
||
// mix helper – browser already supports color-mix; fallback to manual rgba
|
||
const mix = (c, pct) => `color-mix(in srgb, ${c} ${pct}%, transparent)`;
|
||
|
||
// ─── Loading overlay ────────────────────────────────────
|
||
const overlay = document.getElementById('loadingOverlay');
|
||
const showLoading = (text) => {
|
||
if (!overlay) return;
|
||
overlay.style.display = 'flex';
|
||
const txt = document.getElementById('loadingText');
|
||
if (txt && text) txt.innerHTML = `<i class="fas fa-chart-bar me-2"></i>${text}`;
|
||
};
|
||
const hideLoading = () => { if (overlay) overlay.style.display = 'none'; };
|
||
|
||
// ─── Filter helpers ─────────────────────────────────────
|
||
window.setFilter = function (key, value) {
|
||
showLoading('正在更新篩選...');
|
||
const url = new URL(window.location.href);
|
||
if (value === 'all' || value === '' || value == null) url.searchParams.delete(key);
|
||
else url.searchParams.set(key, value);
|
||
window.location.href = url.toString();
|
||
};
|
||
|
||
window.handleDataRangeChange = function (sel) {
|
||
if (!sel.value) return;
|
||
showLoading('正在載入資料...');
|
||
const form = sel.closest('form');
|
||
if (form) form.submit();
|
||
};
|
||
|
||
window.clearDateRange = function () {
|
||
showLoading('正在清除日期...');
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete('start_date');
|
||
url.searchParams.delete('end_date');
|
||
window.location.href = url.toString();
|
||
};
|
||
|
||
window.filterDropdown = function (input) {
|
||
const term = input.value.toLowerCase();
|
||
const items = input.closest('.dropdown-menu').querySelectorAll('.dropdown-item');
|
||
items.forEach((it) => {
|
||
const txt = it.textContent.toLowerCase();
|
||
it.style.display = txt.includes(term) ? '' : 'none';
|
||
});
|
||
};
|
||
|
||
window.showTopDetail = function (kind, metric) {
|
||
const url = new URL('/top_detail', window.location.origin);
|
||
url.searchParams.set('kind', kind);
|
||
url.searchParams.set('metric', metric);
|
||
// forward existing filters
|
||
new URLSearchParams(window.location.search).forEach((v, k) => {
|
||
if (!url.searchParams.has(k)) url.searchParams.set(k, v);
|
||
});
|
||
window.open(url.toString(), '_blank');
|
||
};
|
||
|
||
window.exportMarketingExcel = function (campaign) {
|
||
const url = new URL('/api/export/excel/marketing', window.location.origin);
|
||
url.searchParams.set('campaign', campaign || 'all');
|
||
new URLSearchParams(window.location.search).forEach((v, k) => {
|
||
if (!url.searchParams.has(k)) url.searchParams.set(k, v);
|
||
});
|
||
window.location.href = url.toString();
|
||
};
|
||
|
||
// ─── Flatpickr (zh-tw) ─────────────────────────────────
|
||
const initFlatpickr = () => {
|
||
if (typeof flatpickr === 'undefined') return;
|
||
const opts = {
|
||
dateFormat: 'Y-m-d',
|
||
locale: (flatpickr.l10ns && flatpickr.l10ns.zh_tw) || 'default',
|
||
allowInput: false,
|
||
static: true,
|
||
};
|
||
const start = document.getElementById('start_date');
|
||
const end = document.getElementById('end_date');
|
||
if (start) flatpickr(start, { ...opts, onChange: () => end && end.dispatchEvent(new Event('focus')) });
|
||
if (end) flatpickr(end, opts);
|
||
};
|
||
|
||
// ─── Read embedded data ────────────────────────────────
|
||
const readData = () => {
|
||
const node = document.getElementById('sales-data');
|
||
if (!node) return null;
|
||
try { return JSON.parse(node.textContent); }
|
||
catch (e) { console.warn('[sales-analysis] data parse failed:', e); return null; }
|
||
};
|
||
|
||
// ─── Chart factory helpers ─────────────────────────────
|
||
const baseGrid = { color: mix(INK, 8), drawBorder: false };
|
||
const baseTicks = { color: MUTED, font: { family: getComputedStyle(document.documentElement).fontFamily } };
|
||
|
||
const horizontalBar = (canvas, labels, values, label, fmt) => {
|
||
if (!canvas || !labels || !labels.length) return null;
|
||
return new Chart(canvas, {
|
||
type: 'bar',
|
||
data: {
|
||
labels, datasets: [{
|
||
label, data: values,
|
||
backgroundColor: labels.map((_, i) => PALETTE[i % PALETTE.length]),
|
||
borderRadius: 6, borderSkipped: false,
|
||
}]
|
||
},
|
||
options: {
|
||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: { label: (c) => fmt ? fmt(c.parsed.x) : c.parsed.x.toLocaleString() }
|
||
}
|
||
},
|
||
scales: {
|
||
x: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() } },
|
||
y: { grid: { display: false }, ticks: baseTicks }
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const doughnut = (canvas, labels, values) => {
|
||
if (!canvas || !labels || !labels.length) return null;
|
||
return new Chart(canvas, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels, datasets: [{
|
||
data: values,
|
||
backgroundColor: labels.map((_, i) => PALETTE[i % PALETTE.length]),
|
||
borderColor: SURFACE, borderWidth: 2,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false, cutout: '55%',
|
||
plugins: {
|
||
legend: { position: 'right', labels: { color: INK, font: { size: 11 }, boxWidth: 12 } },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: (c) => {
|
||
const total = c.dataset.data.reduce((a, b) => a + b, 0);
|
||
const pct = total ? (c.parsed / total * 100).toFixed(1) : 0;
|
||
return `${c.label}: ${c.parsed.toLocaleString()} (${pct}%)`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const lineTrend = (canvas, labels, values, label, fmt) => {
|
||
if (!canvas || !labels || !labels.length) return null;
|
||
return new Chart(canvas, {
|
||
type: 'line',
|
||
data: {
|
||
labels, datasets: [{
|
||
label, data: values,
|
||
borderColor: ACCENT,
|
||
backgroundColor: mix(ACCENT, 14),
|
||
tension: 0.34, fill: true, pointBackgroundColor: ACCENT,
|
||
pointRadius: 3, pointHoverRadius: 5, borderWidth: 2.5
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: { callbacks: { label: (c) => fmt ? fmt(c.parsed.y) : c.parsed.y.toLocaleString() } }
|
||
},
|
||
scales: {
|
||
x: { grid: { display: false }, ticks: baseTicks },
|
||
y: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() }, beginAtZero: true }
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const verticalBar = (canvas, labels, values, label, fmt) => {
|
||
if (!canvas || !labels || !labels.length) return null;
|
||
return new Chart(canvas, {
|
||
type: 'bar',
|
||
data: {
|
||
labels, datasets: [{
|
||
label, data: values,
|
||
backgroundColor: labels.map((_, i) => PALETTE[i % PALETTE.length]),
|
||
borderRadius: 5, borderSkipped: false
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => fmt ? fmt(c.parsed.y) : c.parsed.y.toLocaleString() } } },
|
||
scales: {
|
||
x: { grid: { display: false }, ticks: baseTicks },
|
||
y: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmt ? fmt(v) : v.toLocaleString() } }
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const fmtMoney = (v) => '$' + Number(v).toLocaleString();
|
||
const fmtNum = (v) => Number(v).toLocaleString();
|
||
|
||
// ─── BCG quadrant plugin (cross-hair lines) ────────────
|
||
const bcgQuadrants = (xMid, yMid, xMax, yMax) => ({
|
||
id: 'bcgQuadrants',
|
||
beforeDraw(chart) {
|
||
const { ctx, chartArea, scales } = chart;
|
||
if (!chartArea) return;
|
||
const x = scales.x.getPixelForValue(xMid);
|
||
const y = scales.y.getPixelForValue(yMid);
|
||
ctx.save();
|
||
ctx.strokeStyle = mix(MUTED, 30);
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, chartArea.top); ctx.lineTo(x, chartArea.bottom);
|
||
ctx.moveTo(chartArea.left, y); ctx.lineTo(chartArea.right, y);
|
||
ctx.stroke();
|
||
// quadrant labels
|
||
ctx.fillStyle = mix(INK, 50);
|
||
ctx.font = 'bold 11px system-ui';
|
||
ctx.fillText('★ 明星 (Star)', chartArea.left + 12, chartArea.top + 18);
|
||
ctx.fillText('? 問題兒童 (Question)', x + 12, chartArea.top + 18);
|
||
ctx.fillText('🐮 金牛 (Cash Cow)', chartArea.left + 12, chartArea.bottom - 8);
|
||
ctx.fillText('🐶 瘦狗 (Dog)', x + 12, chartArea.bottom - 8);
|
||
ctx.restore();
|
||
}
|
||
});
|
||
|
||
// ─── Build all charts ──────────────────────────────────
|
||
const buildCharts = (data) => {
|
||
if (!data) return;
|
||
|
||
// 1. Top 20 horizontal bar
|
||
if (data.barData) {
|
||
horizontalBar(
|
||
document.getElementById('barChart'),
|
||
data.barData.labels, data.barData.values,
|
||
data.barData.label || '銷售',
|
||
data.barData.is_money ? fmtMoney : fmtNum
|
||
);
|
||
}
|
||
|
||
// 2. Category doughnut
|
||
if (data.categoryData) {
|
||
doughnut(document.getElementById('categoryChart'),
|
||
data.categoryData.labels, data.categoryData.values);
|
||
}
|
||
|
||
// 3. Treemap
|
||
const treemapEl = document.getElementById('treemapChart');
|
||
if (treemapEl && data.treemapData && data.treemapData.length) {
|
||
new Chart(treemapEl, {
|
||
type: 'treemap',
|
||
data: {
|
||
datasets: [{
|
||
tree: data.treemapData,
|
||
key: 'value',
|
||
groups: ['category', 'name'],
|
||
backgroundColor: (ctx) => {
|
||
if (ctx.type !== 'data') return 'transparent';
|
||
const idx = ctx.dataIndex || 0;
|
||
return PALETTE[idx % PALETTE.length];
|
||
},
|
||
borderColor: SURFACE, borderWidth: 1.5, spacing: 2,
|
||
labels: {
|
||
display: true, color: '#fff8ef',
|
||
font: { size: 11, weight: '600' },
|
||
formatter: (c) => c.raw && c.raw._data ? c.raw._data.name : ''
|
||
}
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: { callbacks: { label: (c) => c.raw && c.raw._data ? `${c.raw._data.name}: ${fmtMoney(c.raw.v)}` : '' } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 4. Price distribution (vertical bar)
|
||
if (data.priceDistData) {
|
||
verticalBar(document.getElementById('priceDistChart'),
|
||
data.priceDistData.labels, data.priceDistData.values,
|
||
'業績', fmtMoney);
|
||
}
|
||
|
||
// 5. Scatter
|
||
const scEl = document.getElementById('scatterChart');
|
||
if (scEl && data.scatterData && data.scatterData.length) {
|
||
new Chart(scEl, {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [{
|
||
label: '商品',
|
||
data: data.scatterData,
|
||
backgroundColor: mix(PALETTE[0], 65),
|
||
borderColor: PALETTE[0], borderWidth: 1, pointRadius: 4
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: { callbacks: { label: (c) => `${c.raw.name || ''} — 價:${fmtMoney(c.parsed.x)} 量:${fmtNum(c.parsed.y)}` } }
|
||
},
|
||
scales: {
|
||
x: { title: { display: true, text: '單價 ($)', color: MUTED }, grid: baseGrid, ticks: baseTicks },
|
||
y: { title: { display: true, text: '銷量', color: MUTED }, grid: baseGrid, ticks: baseTicks }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 6. BCG matrix
|
||
const bcgEl = document.getElementById('bcgChart');
|
||
if (bcgEl && data.bcgData && data.bcgData.points && data.bcgData.points.length) {
|
||
const pts = data.bcgData.points;
|
||
const xMid = data.bcgData.x_median || 0;
|
||
const yMid = data.bcgData.y_median || 0;
|
||
new Chart(bcgEl, {
|
||
type: 'scatter',
|
||
data: { datasets: [{
|
||
label: '商品',
|
||
data: pts,
|
||
backgroundColor: pts.map(p => {
|
||
if (p.x >= xMid && p.y >= yMid) return mix(PALETTE[1], 70); // Star
|
||
if (p.x < xMid && p.y >= yMid) return mix(PALETTE[3], 70); // Question
|
||
if (p.x >= xMid && p.y < yMid) return mix(PALETTE[0], 70); // Cash cow
|
||
return mix(MUTED, 40); // Dog
|
||
}),
|
||
borderColor: INK, borderWidth: 0.5, pointRadius: 5
|
||
}]},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: { callbacks: { label: (c) => `${c.raw.name || ''} — 銷量:${fmtNum(c.parsed.x)} 毛利率:${c.parsed.y.toFixed(1)}%` } }
|
||
},
|
||
scales: {
|
||
x: { title: { display: true, text: '銷量 (Qty)', color: MUTED }, grid: baseGrid, ticks: baseTicks },
|
||
y: { title: { display: true, text: '毛利率 (%)', color: MUTED }, grid: baseGrid, ticks: baseTicks }
|
||
}
|
||
},
|
||
plugins: [bcgQuadrants(xMid, yMid)]
|
||
});
|
||
}
|
||
|
||
// 7. Seasonality heatmap (matrix-style via grouped bars stacked – fallback)
|
||
const seasEl = document.getElementById('seasonalityChart');
|
||
if (seasEl && data.seasonalityData && data.seasonalityData.matrix) {
|
||
const cats = data.seasonalityData.categories;
|
||
const months = data.seasonalityData.months;
|
||
// build datasets: one per category
|
||
const datasets = cats.map((cat, i) => ({
|
||
label: cat,
|
||
data: data.seasonalityData.matrix[i] || [],
|
||
backgroundColor: PALETTE[i % PALETTE.length],
|
||
borderRadius: 3
|
||
}));
|
||
new Chart(seasEl, {
|
||
type: 'bar',
|
||
data: { labels: months, datasets },
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: { legend: { position: 'right', labels: { color: INK, font: { size: 10 }, boxWidth: 10 } } },
|
||
scales: {
|
||
x: { stacked: true, grid: { display: false }, ticks: baseTicks },
|
||
y: { stacked: true, grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmtMoney(v) } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 8. Marketing — discount + coupon
|
||
if (data.marketingData) {
|
||
if (data.marketingData.discount) {
|
||
horizontalBar(document.getElementById('mktDiscountChart'),
|
||
data.marketingData.discount.labels, data.marketingData.discount.values,
|
||
'折扣業績', fmtMoney);
|
||
}
|
||
if (data.marketingData.coupon) {
|
||
horizontalBar(document.getElementById('mktCouponChart'),
|
||
data.marketingData.coupon.labels, data.marketingData.coupon.values,
|
||
'折價券業績', fmtMoney);
|
||
}
|
||
}
|
||
|
||
// 9. Time-dimension trends
|
||
if (data.monthlyData) {
|
||
lineTrend(document.getElementById('monthlyChart'),
|
||
data.monthlyData.labels, data.monthlyData.values, '月業績', fmtMoney);
|
||
}
|
||
if (data.weeklyData) {
|
||
lineTrend(document.getElementById('weeklyChart'),
|
||
data.weeklyData.labels, data.weeklyData.values, '週業績', fmtMoney);
|
||
}
|
||
if (data.dowData) {
|
||
verticalBar(document.getElementById('dowChart'),
|
||
data.dowData.labels, data.dowData.values, '日業績', fmtMoney);
|
||
}
|
||
if (data.hourlyData) {
|
||
verticalBar(document.getElementById('hourlyChart'),
|
||
data.hourlyData.labels, data.hourlyData.values, '時業績', fmtMoney);
|
||
}
|
||
|
||
// 10. Heatmap (DOW × Hour) — stacked bar fallback
|
||
const hmEl = document.getElementById('heatmapChart');
|
||
if (hmEl && data.heatmapData && data.heatmapData.matrix) {
|
||
const dows = data.heatmapData.dows || ['週一','週二','週三','週四','週五','週六','週日'];
|
||
const hours = data.heatmapData.hours || Array.from({length: 24}, (_, i) => `${i}:00`);
|
||
const datasets = hours.map((h, i) => ({
|
||
label: h,
|
||
data: data.heatmapData.matrix.map(row => row[i] || 0),
|
||
backgroundColor: mix(ACCENT, Math.max(8, (i / 24) * 80)),
|
||
stack: 'hm'
|
||
}));
|
||
new Chart(hmEl, {
|
||
type: 'bar',
|
||
data: { labels: dows, datasets },
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => `${c.dataset.label}: ${fmtMoney(c.parsed.y)}` } } },
|
||
scales: {
|
||
x: { stacked: true, grid: { display: false }, ticks: baseTicks },
|
||
y: { stacked: true, grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmtMoney(v) } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
// ─── YoY ───────────────────────────────────────────────
|
||
let yoyChart = null;
|
||
window.loadYoYData = function () {
|
||
const y1 = document.getElementById('yoy-year1').value;
|
||
const y2 = document.getElementById('yoy-year2').value;
|
||
const m = document.getElementById('yoy-metric').value;
|
||
fetch(`/api/yoy?year1=${y1}&year2=${y2}&metric=${m}&${window.location.search.slice(1)}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
document.getElementById('yoy-year1-label').textContent = `${y1}年`;
|
||
document.getElementById('yoy-year2-label').textContent = `${y2}年`;
|
||
document.getElementById('yoy-year1-value').textContent = fmtMoney(d.total1 || 0);
|
||
document.getElementById('yoy-year2-value').textContent = fmtMoney(d.total2 || 0);
|
||
const growth = d.growth || 0;
|
||
const card = document.getElementById('yoy-growth-card');
|
||
card.classList.toggle('is-decline', growth < 0);
|
||
const arrow = growth >= 0 ? 'up' : 'down';
|
||
document.getElementById('yoy-growth-value').innerHTML =
|
||
`<i class="fas fa-arrow-${arrow}"></i> ${growth.toFixed(1)}%`;
|
||
|
||
const ctx = document.getElementById('yoy-chart');
|
||
if (yoyChart) yoyChart.destroy();
|
||
yoyChart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: d.labels || [],
|
||
datasets: [
|
||
{ label: `${y1}年`, data: d.values1 || [], borderColor: MUTED,
|
||
backgroundColor: mix(MUTED, 8), tension: 0.3, fill: false, borderWidth: 2 },
|
||
{ label: `${y2}年`, data: d.values2 || [], borderColor: ACCENT,
|
||
backgroundColor: mix(ACCENT, 12), tension: 0.3, fill: true, borderWidth: 2.5 }
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: { legend: { position: 'top', labels: { color: INK, boxWidth: 12 } } },
|
||
scales: {
|
||
x: { grid: { display: false }, ticks: baseTicks },
|
||
y: { grid: baseGrid, ticks: { ...baseTicks, callback: (v) => fmtMoney(v) } }
|
||
}
|
||
}
|
||
});
|
||
})
|
||
.catch(e => console.warn('[yoy] fetch failed', e));
|
||
};
|
||
|
||
// ─── DataTable ─────────────────────────────────────────
|
||
const initDataTable = () => {
|
||
const $tbl = window.jQuery && jQuery('#dataTable');
|
||
if (!$tbl || !$tbl.length || !jQuery.fn.DataTable) return;
|
||
const url = '/api/sales_table?' + window.location.search.slice(1);
|
||
$tbl.DataTable({
|
||
ajax: { url, dataSrc: 'data' },
|
||
processing: true, serverSide: false,
|
||
pageLength: 25, lengthMenu: [10, 25, 50, 100],
|
||
order: [[$tbl.find('thead th').length - 1, 'desc']],
|
||
language: { url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' },
|
||
columnDefs: [{ targets: '_all', defaultContent: '—' }]
|
||
});
|
||
};
|
||
|
||
// ─── Boot ──────────────────────────────────────────────
|
||
const boot = () => {
|
||
initFlatpickr();
|
||
const data = readData();
|
||
if (data) buildCharts(data);
|
||
initDataTable();
|
||
if (typeof window.loadYoYData === 'function' && document.getElementById('yoy-chart')) {
|
||
try { window.loadYoYData(); } catch (e) { /* silent */ }
|
||
}
|
||
// tooltips
|
||
if (window.bootstrap) {
|
||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
|
||
}
|
||
hideLoading();
|
||
};
|
||
|
||
// Show loading on form submit
|
||
document.addEventListener('submit', (e) => {
|
||
if (e.target.matches('form[action="/sales_analysis"]')) showLoading('正在查詢...');
|
||
});
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot);
|
||
} else {
|
||
boot();
|
||
}
|
||
})();
|