Files
ewoooc/templates/sales_analysis.html
OoO 53edcc0077 refactor(templates): 統一模板目錄並移除 fallback loader
ADR-017 Phase 3f-4:根目錄模板搬入 templates/,補 trends/login_history,移除 ChoiceLoader 根目錄 fallback,搬移 components,刪除 web/templates 下的空檔/死檔與 compose 舊模板 mount。
2026-04-29 21:44:38 +08:00

3166 lines
150 KiB
HTML
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.
<!-- cspell:ignore MOMO datatables Treemap -->
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>業績分析 - WOOO TECH</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.11.5/css/dataTables.bootstrap5.min.css">
<!-- V-New: Flatpickr 日期選擇器 CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<!-- V-Fix: 使用 Chart.js v3.9.1 以確保與 Treemap v2.0.2 相容 -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-treemap@2.0.2/dist/chartjs-chart-treemap.min.js"></script>
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f4f6f9;
padding-top: 70px;
}
.navbar-dark.bg-primary {
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.navbar-dark .navbar-brand {
color: #ffffff !important;
font-weight: 600;
}
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
transition: all 0.3s;
}
.navbar-dark .navbar-nav .nav-link:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.navbar-dark .navbar-nav .nav-link.active {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-weight: 600;
}
.navbar-dark .navbar-text {
color: rgba(255, 255, 255, 0.8) !important;
}
.navbar {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%) !important;
}
/* V-Fix: 防止導航列 dropdown 展開時背景變黑 */
.navbar-dark .nav-link.active,
.navbar-dark .nav-link.show,
.navbar-dark .nav-link:focus {
background-color: transparent !important;
}
.navbar-dark .dropdown-toggle.active::after,
.navbar-dark .dropdown-toggle.show::after {
color: #fff !important;
}
/* V-Opt: 現代化卡片風格 */
.card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03);
margin-bottom: 1.5rem;
transition: all 0.3s ease;
background: #fff;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08);
}
.card-header {
background-color: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
font-weight: 700;
color: #2c3e50;
padding: 1.25rem;
}
/* V-Opt: 表單控制項優化 */
.form-label {
font-weight: 600;
font-size: 0.85rem;
color: #6c757d;
margin-bottom: 0.4rem;
}
.input-group-text {
background-color: #f8f9fa;
border-color: #dee2e6;
color: #6c757d;
}
.form-control,
.form-select {
border-color: #dee2e6;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.95rem;
}
.form-control:focus,
.form-select:focus {
border-color: #3498db;
box-shadow: 0 0 0 4px rgba(52, 152, 219, 0.1);
}
.btn {
border-radius: 8px;
padding: 0.6rem 1.2rem;
font-weight: 600;
transition: all 0.2s;
}
/* V-Fix: 確保下拉選單能正確顯示並在所有內容之上 */
.dropdown-menu {
z-index: 1050 !important;
}
.kpi-card {
position: relative;
overflow: hidden;
border: none;
}
.kpi-card .icon-bg {
position: absolute;
right: -15px;
bottom: -15px;
font-size: 6rem;
opacity: 0.15;
transform: rotate(-15deg);
pointer-events: none;
}
.kpi-value {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.5px;
margin-bottom: 0.2rem;
}
.kpi-label {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.9;
}
.table th {
font-weight: 600;
color: #495057;
background-color: #f8f9fa;
}
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Loading Overlay */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(240, 245, 255, 0.95) 100%);
z-index: 9999;
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 25px;
backdrop-filter: blur(8px);
}
/* LOGO 動畫容器 */
#loadingOverlay .loading-logo-container {
position: relative;
width: 160px;
height: 160px;
display: flex;
justify-content: center;
align-items: center;
}
#loadingOverlay .loading-logo {
z-index: 3;
animation: cloud-float 3s ease-in-out infinite;
/* V-Fix: 極簡設計 - 純漸層文字 */
font-size: 2.5rem;
font-weight: 800;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 4px;
filter: drop-shadow(0 4px 15px rgba(79, 70, 229, 0.4));
}
/* V-New: 雲端飄動動畫 - 上下+左右微移動 */
@keyframes cloud-float {
0%,
100% {
transform: translateY(0) translateX(0) scale(1);
}
25% {
transform: translateY(-15px) translateX(5px) scale(1.02);
}
50% {
transform: translateY(-8px) translateX(-3px) scale(1);
}
75% {
transform: translateY(-20px) translateX(3px) scale(1.01);
}
}
@keyframes logo-float {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-8px) rotate(2deg);
}
50% {
transform: translateY(-12px) rotate(0deg);
}
75% {
transform: translateY(-8px) rotate(-2deg);
}
}
/* 外層旋轉光環 */
#loadingOverlay .logo-ring {
position: absolute;
width: 150px;
height: 150px;
border: 4px solid transparent;
border-top-color: #4F46E5;
border-right-color: #7C3AED;
border-radius: 50%;
animation: ring-spin 2s linear infinite;
}
/* 內層反向旋轉光環 */
#loadingOverlay .logo-ring-inner {
position: absolute;
width: 120px;
height: 120px;
border: 3px solid transparent;
border-bottom-color: #EC4899;
border-left-color: #F59E0B;
border-radius: 50%;
animation: ring-spin-reverse 1.5s linear infinite;
}
/* 脈衝光暈 */
#loadingOverlay .logo-pulse {
position: absolute;
width: 100px;
height: 100px;
background: radial-gradient(circle, rgba(79, 70, 229, 0.3) 0%, transparent 70%);
border-radius: 50%;
animation: pulse-expand 2s ease-out infinite;
}
@keyframes ring-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes ring-spin-reverse {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}
@keyframes pulse-expand {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(1.8);
opacity: 0;
}
}
/* 環繞粒子 */
#loadingOverlay .orbit-particles {
position: absolute;
width: 160px;
height: 160px;
animation: orbit-rotate 4s linear infinite;
}
#loadingOverlay .orbit-particle {
position: absolute;
width: 8px;
height: 8px;
background: linear-gradient(135deg, #4F46E5, #7C3AED);
border-radius: 50%;
box-shadow: 0 0 10px rgba(79, 70, 229, 0.8);
}
#loadingOverlay .orbit-particle:nth-child(1) {
top: 0;
left: 50%;
transform: translateX(-50%);
}
#loadingOverlay .orbit-particle:nth-child(2) {
top: 50%;
right: 0;
transform: translateY(-50%);
}
#loadingOverlay .orbit-particle:nth-child(3) {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
#loadingOverlay .orbit-particle:nth-child(4) {
top: 50%;
left: 0;
transform: translateY(-50%);
}
@keyframes orbit-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 閃爍星星 */
#loadingOverlay .sparkles {
position: absolute;
width: 180px;
height: 180px;
}
#loadingOverlay .sparkle {
position: absolute;
width: 4px;
height: 4px;
background: #FCD34D;
border-radius: 50%;
animation: sparkle-twinkle 1.5s ease-in-out infinite;
}
#loadingOverlay .sparkle:nth-child(1) {
top: 10%;
left: 20%;
animation-delay: 0s;
}
#loadingOverlay .sparkle:nth-child(2) {
top: 5%;
right: 25%;
animation-delay: 0.3s;
}
#loadingOverlay .sparkle:nth-child(3) {
bottom: 15%;
right: 15%;
animation-delay: 0.6s;
}
#loadingOverlay .sparkle:nth-child(4) {
bottom: 10%;
left: 25%;
animation-delay: 0.9s;
}
#loadingOverlay .sparkle:nth-child(5) {
top: 30%;
left: 5%;
animation-delay: 1.2s;
}
#loadingOverlay .sparkle:nth-child(6) {
top: 25%;
right: 5%;
animation-delay: 0.4s;
}
@keyframes sparkle-twinkle {
0%,
100% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1);
opacity: 1;
}
}
#loadingOverlay .loading-text {
font-size: 1.2rem;
color: #4F46E5;
font-weight: 600;
text-align: center;
letter-spacing: 0.5px;
}
#loadingOverlay .loading-hint {
font-size: 0.85rem;
color: #6b7280;
text-align: center;
max-width: 300px;
}
#loadingOverlay .loading-progress {
width: 200px;
height: 4px;
background: rgba(79, 70, 229, 0.2);
border-radius: 2px;
overflow: hidden;
}
#loadingOverlay .loading-progress-bar {
height: 100%;
background: linear-gradient(90deg, #4F46E5, #7C3AED, #4F46E5);
background-size: 200% 100%;
animation: progress-flow 1.5s linear infinite;
width: 100%;
}
@keyframes progress-flow {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
/* V-New: Flatpickr 樣式優化 */
.flatpickr-input {
cursor: pointer;
}
.flatpickr-calendar {
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15) !important;
border: none !important;
}
.flatpickr-day.selected {
background: #3498db !important;
border-color: #3498db !important;
}
.flatpickr-day.selected:hover {
background: #2980b9 !important;
}
.flatpickr-day:hover {
background: #ecf0f1;
border-color: #ecf0f1;
}
.flatpickr-months .flatpickr-month {
background: #3498db;
color: white;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
background: #3498db;
}
.flatpickr-current-month .numInputWrapper {
color: white;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
background: #2980b9;
}
/* Custom Dark Gray Navbar */
.navbar.bg-custom-dark {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.navbar.bg-custom-dark .navbar-brand {
color: #ffffff;
font-weight: 600;
}
.navbar.bg-custom-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.navbar.bg-custom-dark .navbar-nav .nav-link:hover {
color: #ffffff;
}
.navbar.bg-custom-dark .navbar-nav .nav-link.active {
color: #ffffff;
font-weight: 600;
}
.navbar.bg-custom-dark .navbar-text {
color: rgba(255, 255, 255, 0.75);
}
</style>
</head>
<body class="bg-body-tertiary">
{% include 'components/_navbar.html' %}
<!-- Loading Overlay -->
<div id="loadingOverlay">
<div class="loading-logo-container">
<!-- 脈衝光暈 -->
<div class="logo-pulse"></div>
<!-- 外層旋轉光環 -->
<div class="logo-ring"></div>
<!-- 內層反向旋轉光環 -->
<div class="logo-ring-inner"></div>
<!-- 環繞粒子 -->
<div class="orbit-particles">
<div class="orbit-particle"></div>
<div class="orbit-particle"></div>
<div class="orbit-particle"></div>
<div class="orbit-particle"></div>
</div>
<!-- 閃爍星星 -->
<div class="sparkles">
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
</div>
<!-- LOGO (CSS 雲朵外框版) -->
<div class="loading-logo">WOOO</div>
</div>
<div class="loading-text" id="loadingText">
<i class="fas fa-chart-bar me-2"></i>正在載入數據...
</div>
<div class="loading-progress">
<div class="loading-progress-bar"></div>
</div>
<div class="loading-hint" id="loadingHint">
大量資料可能需要較長時間,請稍候
</div>
</div>
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4 mt-4">
<div class="d-flex align-items-center gap-3">
<h4 class="mb-0 fw-bold text-dark"><i class="fas fa-chart-pie me-2 text-primary"></i>業績分析儀表板</h4>
{% if db_data_range %}
<span class="badge bg-secondary" style="font-size: 0.85rem; font-weight: normal;">
<i class="fas fa-calendar-alt me-1"></i>資料期間: {{ db_data_range }}
</span>
{% endif %}
</div>
<div class="text-end">
{% if not no_filter %}
<span class="badge bg-success">
<i class="fas fa-database me-1"></i>
{% if start_date or end_date %}
{% if start_date and end_date %}
{{ start_date }} ~ {{ end_date }}
{% elif start_date %}
{{ start_date }} 起
{% else %}
{{ end_date }} 止
{% endif %}
{% elif data_range_months == 0 %}全部資料
{% else %}最近 {{ data_range_months }} 個月
{% endif %}
| {{ "{:,}".format(total_records) }} 筆
</span>
{% endif %}
</div>
</div>
{% if error %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>{{ error }}
<div class="mt-2">
<a href="/settings" class="btn btn-sm btn-outline-dark">前往匯入資料</a>
</div>
</div>
{% else %}
<!-- V-New: 控制面板 (篩選器) -->
<div class="card mb-4 shadow-sm" style="z-index: 100; transform: none !important; transition: none;">
<div class="card-header bg-gradient"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none;">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0 text-white"><i class="fas fa-sliders-h me-2"></i>進階篩選與分析</h5>
<!-- V-New: 分析維度切換(更顯眼的設計) -->
<div class="btn-group shadow-sm" role="group">
<input type="radio" class="btn-check" name="metric" id="metricAmount" value="amount"
autocomplete="off" onchange="setFilter('metric', 'amount')" {% if selected_metric=='amount'
%}checked{% endif %}>
<label class="btn btn-sm btn-light fw-bold" for="metricAmount">
<i class="fas fa-dollar-sign me-1"></i>依金額分析
</label>
<input type="radio" class="btn-check" name="metric" id="metricQty" value="qty"
autocomplete="off" onchange="setFilter('metric', 'qty')" {% if selected_metric=='qty'
%}checked{% endif %}>
<label class="btn btn-sm btn-light fw-bold" for="metricQty">
<i class="fas fa-box me-1"></i>依銷售量分析
</label>
{% if cols.cost or cols.profit %}
<input type="radio" class="btn-check" name="metric" id="metricProfit" value="profit"
autocomplete="off" onchange="setFilter('metric', 'profit')" {% if selected_metric=='profit'
%}checked{% endif %}>
<label class="btn btn-sm btn-light fw-bold" for="metricProfit">
<i class="fas fa-chart-line me-1"></i>依毛利分析
</label>
{% endif %}
</div>
</div>
</div>
<div class="card-body pt-4 pb-4" style="background-color: #f8f9fa;">
<form method="GET" action="/sales_analysis" class="row g-3">
<!-- 隱藏欄位:保留 metric 狀態 -->
<input type="hidden" name="metric" value="{{ selected_metric }}">
<!-- ═══════ 第一區:資料範圍 ═══════ -->
<div class="col-12">
<div class="bg-white rounded-3 p-3 shadow-sm">
<h6 class="text-secondary mb-3 fw-bold border-bottom pb-2">
<i class="fas fa-calendar-check me-2 text-primary"></i>資料範圍設定
</h6>
<div class="row g-2">
<!-- 資料載入範圍 -->
<div class="col-md-3">
<label class="form-label"><i
class="fas fa-database me-1 text-primary"></i>資料載入範圍</label>
<select name="data_range" class="form-select"
onchange="handleDataRangeChange(this)">
<option value="" {% if not request.args.get('data_range') %}selected{% endif %}>
-- 請選擇 --</option>
<option value="1" {% if request.args.get('data_range')=='1' %}selected{% endif
%}>最近 1 個月 (推薦)</option>
<option value="3" {% if request.args.get('data_range')=='3' %}selected{% endif
%}>最近 3 個月</option>
<option value="6" {% if request.args.get('data_range')=='6' %}selected{% endif
%}>最近 6 個月</option>
<option value="12" {% if request.args.get('data_range')=='12' %}selected{% endif
%}>最近 12 個月</option>
<option value="0" {% if request.args.get('data_range')=='0' %}selected{% endif
%}>全部資料</option>
</select>
</div>
<!-- 開始日期 -->
<div class="col-md-3">
<label class="form-label"><i class="fas fa-calendar-alt me-1 text-success"></i>開始日期
<small class="text-muted">(選填)</small></label>
<input type="text" id="start_date" name="start_date"
class="form-control flatpickr-input" placeholder="選擇開始日期"
value="{{ request.args.get('start_date', '') }}" readonly="readonly">
</div>
<!-- 結束日期 -->
<div class="col-md-3">
<label class="form-label"><i
class="fas fa-calendar-check me-1 text-success"></i>結束日期 <small
class="text-muted">(選填)</small></label>
<div class="input-group">
<input type="text" id="end_date" name="end_date"
class="form-control flatpickr-input" placeholder="選擇結束日期"
value="{{ request.args.get('end_date', '') }}" readonly="readonly">
{% if request.args.get('start_date') or request.args.get('end_date') %}
<button type="button" class="btn btn-outline-danger" onclick="clearDateRange()"
title="清除日期範圍">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
</div>
<!-- 操作按鈕 -->
<div class="col-md-3 d-flex align-items-end gap-2">
<a href="/sales_analysis" class="btn btn-outline-secondary flex-fill">
<i class="fas fa-redo me-1"></i>重置
</a>
<button type="submit" class="btn btn-primary flex-fill">
<i class="fas fa-search me-1"></i>查詢
</button>
</div>
</div>
</div>
</div>
<!-- ═══════ 第二區:商品屬性篩選 ═══════ -->
<div class="col-12">
<div class="bg-white rounded-3 p-3 shadow-sm">
<h6 class="text-secondary mb-3 fw-bold border-bottom pb-2">
<i class="fas fa-tags me-2 text-info"></i>商品屬性篩選
</h6>
<div class="row g-2">
<!-- 商品分類 -->
<div class="col-md-4">
<label class="form-label"><i class="fas fa-layer-group me-1"></i>商品分類</label>
<div class="btn-group w-100">
<button type="button"
class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-layer-group me-2 text-muted"></i>
{{ selected_category if selected_category != 'all' else '全部分類' }}
</button>
<ul class="dropdown-menu w-100"
style="max-height: 300px; overflow-y: auto; transform: none !important; top: 100% !important; left: 0 !important; right: auto !important;">
<li class="px-2 py-1">
<input type="text" class="form-control form-control-sm"
placeholder="搜尋..." onkeyup="filterDropdown(this)">
</li>
<li><a class="dropdown-item"
href="javascript:setFilter('category', 'all')">全部分類</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for cat in all_categories %}
<li><a class="dropdown-item"
href="javascript:setFilter('category', '{{ cat }}')">{{ cat }}</a>
</li>
{% endfor %}
</ul>
<input type="hidden" name="category" value="{{ selected_category }}">
</div>
</div>
<!-- 品牌 -->
{% if all_brands %}
<div class="col-md-4">
<label class="form-label"><i class="fas fa-tag me-1"></i>品牌</label>
<div class="btn-group w-100">
<button type="button"
class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-tag me-2 text-muted"></i>
{{ selected_brand if selected_brand != 'all' else '全部品牌' }}
</button>
<ul class="dropdown-menu w-100"
style="max-height: 300px; overflow-y: auto; transform: none !important; top: 100% !important; left: 0 !important; right: auto !important;">
<li class="px-2 py-1">
<input type="text" class="form-control form-control-sm"
placeholder="搜尋..." onkeyup="filterDropdown(this)">
</li>
<li><a class="dropdown-item"
href="javascript:setFilter('brand', 'all')">全部品牌</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for b in all_brands %}
<li><a class="dropdown-item"
href="javascript:setFilter('brand', '{{ b }}')">{{ b }}</a></li>
{% endfor %}
</ul>
<input type="hidden" name="brand" value="{{ selected_brand }}">
</div>
</div>
{% endif %}
<!-- 供應商 -->
{% if all_vendors %}
<div class="col-md-4">
<label class="form-label"><i class="fas fa-truck me-1"></i>供應商</label>
<div class="btn-group w-100">
<button type="button"
class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-truck me-2 text-muted"></i>
{{ selected_vendor if selected_vendor != 'all' else '全部廠商' }}
</button>
<ul class="dropdown-menu w-100"
style="max-height: 300px; overflow-y: auto; transform: none !important; top: 100% !important; left: 0 !important; right: auto !important;">
<li class="px-2 py-1">
<input type="text" class="form-control form-control-sm"
placeholder="搜尋..." onkeyup="filterDropdown(this)">
</li>
<li><a class="dropdown-item"
href="javascript:setFilter('vendor', 'all')">全部廠商</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for v in all_vendors %}
<li><a class="dropdown-item"
href="javascript:setFilter('vendor', '{{ v }}')">{{ v }}</a></li>
{% endfor %}
</ul>
<input type="hidden" name="vendor" value="{{ selected_vendor }}">
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ═══════ 第三區:行銷與付款(選填) ═══════ -->
{% if all_activities or all_payments %}
<div class="col-12">
<div class="bg-white rounded-3 p-3 shadow-sm">
<h6 class="text-secondary mb-3 fw-bold border-bottom pb-2">
<i class="fas fa-bullhorn me-2 text-warning"></i>行銷與付款
</h6>
<div class="row g-2">
<!-- 行銷活動 -->
{% if all_activities %}
<div class="col-md-6">
<label class="form-label"><i class="fas fa-bullhorn me-1"></i>行銷活動</label>
<div class="btn-group w-100">
<button type="button"
class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-bullhorn me-2 text-muted"></i>
{{ selected_activity if selected_activity != 'all' else '全部活動' }}
</button>
<ul class="dropdown-menu w-100"
style="max-height: 300px; overflow-y: auto; transform: none !important; top: 100% !important; left: 0 !important; right: auto !important;">
<li class="px-2 py-1">
<input type="text" class="form-control form-control-sm"
placeholder="搜尋..." onkeyup="filterDropdown(this)">
</li>
<li><a class="dropdown-item"
href="javascript:setFilter('activity', 'all')">全部活動</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for a in all_activities %}
<li><a class="dropdown-item"
href="javascript:setFilter('activity', '{{ a }}')">{{ a }}</a></li>
{% endfor %}
</ul>
<input type="hidden" name="activity" value="{{ selected_activity }}">
</div>
</div>
{% endif %}
<!-- 付款方式 -->
{% if all_payments %}
<div class="col-md-6">
<label class="form-label"><i class="fas fa-credit-card me-1"></i>付款方式</label>
<div class="btn-group w-100">
<button type="button"
class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-credit-card me-2 text-muted"></i>
{{ selected_payment if selected_payment != 'all' else '全部付款方式' }}
</button>
<ul class="dropdown-menu w-100"
style="max-height: 300px; overflow-y: auto; transform: none !important; top: 100% !important; left: 0 !important; right: auto !important;">
<li class="px-2 py-1">
<input type="text" class="form-control form-control-sm"
placeholder="搜尋..." onkeyup="filterDropdown(this)">
</li>
<li><a class="dropdown-item"
href="javascript:setFilter('payment', 'all')">全部付款方式</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for p in all_payments %}
<li><a class="dropdown-item"
href="javascript:setFilter('payment', '{{ p }}')">{{ p }}</a></li>
{% endfor %}
</ul>
<input type="hidden" name="payment" value="{{ selected_payment }}">
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- ═══════ 第四區:時間維度(選填) ═══════ -->
{% if cols.date %}
<div class="col-12">
<div class="bg-white rounded-3 p-3 shadow-sm">
<h6 class="text-secondary mb-3 fw-bold border-bottom pb-2">
<i class="fas fa-clock me-2 text-danger"></i>時間維度篩選
</h6>
<div class="row g-2">
<!-- 月份 -->
<div class="col-md-4">
<label class="form-label"><i class="fas fa-calendar-alt me-1"></i>月份
<!-- V-Debug: 總數 {{ all_months|length }} --></label>
<div class="btn-group w-100">
<button type="button"
class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-calendar-alt me-2 text-muted"></i>
{{ selected_month if selected_month != 'all' else '全部月份' }}
</button>
<ul class="dropdown-menu w-100"
style="max-height: 300px; overflow-y: auto; z-index: 1060;">
<li><a class="dropdown-item"
href="javascript:setFilter('month', 'all')">全部月份</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for m in all_months %}
<li><a class="dropdown-item"
href="javascript:setFilter('month', '{{ m }}')">{{ m }}</a></li>
{% endfor %}
</ul>
<input type="hidden" name="month" value="{{ selected_month }}">
</div>
</div>
<!-- 星期 -->
<div class="col-md-4">
<label class="form-label"><i class="fas fa-calendar-day me-1"></i>星期</label>
<div class="btn-group w-100">
<button type="button"
class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-calendar-day me-2 text-muted"></i>
{% if selected_dow == 'all' %}全部星期
{% elif selected_dow == '0' %}週一
{% elif selected_dow == '1' %}週二
{% elif selected_dow == '2' %}週三
{% elif selected_dow == '3' %}週四
{% elif selected_dow == '4' %}週五
{% elif selected_dow == '5' %}週六
{% elif selected_dow == '6' %}週日
{% endif %}
</button>
<ul class="dropdown-menu w-100"
style="transform: none !important; top: 100% !important; left: 0 !important; right: auto !important;">
<li><a class="dropdown-item"
href="javascript:setFilter('dow', 'all')">全部星期</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item" href="javascript:setFilter('dow', '0')">週一</a>
</li>
<li><a class="dropdown-item" href="javascript:setFilter('dow', '1')">週二</a>
</li>
<li><a class="dropdown-item" href="javascript:setFilter('dow', '2')">週三</a>
</li>
<li><a class="dropdown-item" href="javascript:setFilter('dow', '3')">週四</a>
</li>
<li><a class="dropdown-item" href="javascript:setFilter('dow', '4')">週五</a>
</li>
<li><a class="dropdown-item" href="javascript:setFilter('dow', '5')">週六</a>
</li>
<li><a class="dropdown-item" href="javascript:setFilter('dow', '6')">週日</a>
</li>
</ul>
<input type="hidden" name="dow" value="{{ selected_dow }}">
</div>
</div>
<!-- 時段 -->
<div class="col-md-4">
<label class="form-label"><i class="fas fa-clock me-1"></i>時段</label>
<div class="btn-group w-100">
<button type="button"
class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-clock me-2 text-muted"></i>
{{ selected_hour + ':00' if selected_hour != 'all' else '全部時段' }}
</button>
<ul class="dropdown-menu w-100"
style="max-height: 300px; overflow-y: auto; transform: none !important; top: 100% !important; left: 0 !important; right: auto !important;">
<li><a class="dropdown-item"
href="javascript:setFilter('hour', 'all')">全部時段</a></li>
<li>
<hr class="dropdown-divider">
</li>
{% for h in range(24) %}
<li><a class="dropdown-item"
href="javascript:setFilter('hour', '{{ h }}')">{{ "%02d"|format(h)
}}:00</a></li>
{% endfor %}
</ul>
<input type="hidden" name="hour" value="{{ selected_hour }}">
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- ═══════ 第五區:數值與關鍵字(選填) ═══════ -->
<div class="col-12">
<div class="bg-white rounded-3 p-3 shadow-sm">
<h6 class="text-secondary mb-3 fw-bold border-bottom pb-2">
<i class="fas fa-search me-2 text-success"></i>數值與關鍵字篩選
</h6>
<div class="row g-2">
<!-- 單價區間 -->
<div class="col-md-4">
<label class="form-label"><i class="fas fa-dollar-sign me-1"></i>單價區間 ($)</label>
<div class="input-group">
<input type="number" name="min_price" class="form-control" placeholder="Min"
value="{{ min_price }}">
<span
class="input-group-text bg-white border-start-0 border-end-0 text-muted">~</span>
<input type="number" name="max_price" class="form-control" placeholder="Max"
value="{{ max_price }}">
</div>
</div>
<!-- 毛利率區間 -->
{% if cols.cost or cols.profit %}
<div class="col-md-4">
<label class="form-label"><i class="fas fa-percentage me-1"></i>毛利率區間 (%)</label>
<div class="input-group">
<input type="number" name="min_margin" class="form-control" placeholder="Min"
value="{{ min_margin }}">
<span
class="input-group-text bg-white border-start-0 border-end-0 text-muted">~</span>
<input type="number" name="max_margin" class="form-control" placeholder="Max"
value="{{ max_margin }}">
</div>
</div>
{% endif %}
<!-- 關鍵字搜尋 -->
<div class="col-md-4">
<label class="form-label"><i class="fas fa-search me-1"></i>關鍵字搜尋</label>
<div class="input-group">
<span class="input-group-text bg-white"><i class="fas fa-search"></i></span>
<input type="text" name="keyword" class="form-control border-start-0"
placeholder="輸入商品名稱..." value="{{ keyword }}">
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- V-New: 引導訊息區域(首次進入時顯示) -->
{% if no_filter %}
<div class="row justify-content-center mt-5">
<div class="col-md-8">
<div class="card border-0 shadow-lg">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="fas fa-chart-line" style="font-size: 5rem; color: #0d6efd; opacity: 0.3;"></i>
</div>
<h3 class="text-primary mb-3">
<i class="fas fa-hand-point-up me-2"></i>開始分析您的業績數據
</h3>
<p class="text-muted mb-4" style="font-size: 1.1rem;">
請在上方<strong>「進階篩選」</strong>區域選擇以下任一條件開始分析:
</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card bg-light border-primary h-100">
<div class="card-body">
<h5 class="text-primary mb-2">
<i class="fas fa-database me-2"></i>資料載入範圍
</h5>
<p class="small text-muted mb-0">快速選擇最近 1/3/6/12 個月或全部資料</p>
<p class="small text-success mb-0 mt-2"><i class="fas fa-star me-1"></i>推薦新手使用
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-light border-success h-100">
<div class="card-body">
<h5 class="text-success mb-2">
<i class="fas fa-calendar-alt me-2"></i>自訂日期區間
</h5>
<p class="small text-muted mb-0">精確指定開始/結束日期進行分析</p>
<p class="small text-info mb-0 mt-2"><i class="fas fa-clock me-1"></i>適合特定區間分析
</p>
</div>
</div>
</div>
</div>
<div class="alert alert-info d-inline-block">
<i class="fas fa-info-circle me-2"></i>
<strong>提示:</strong>選擇任一條件後,系統將自動載入並顯示詳細的圖表與數據分析
</div>
</div>
</div>
</div>
</div>
{% else %}
<!-- KPI 區塊 -->
<div class="row mb-4">
<!-- 1. 總業績 (最重要) -->
<div class="col-md-2">
<div class="card kpi-card bg-primary text-white h-100 shadow-sm">
<div class="card-body p-3">
<div class="kpi-label text-white-50">總業績 (Revenue)</div>
<div class="kpi-value">${{ "{:,.0f}".format(kpi.revenue) }}</div>
<i class="fas fa-coins icon-bg"></i>
</div>
</div>
</div>
<!-- 2. 總成本 (若有) -->
{% if cols.cost or cols.profit %}
<div class="col-md-2">
<div class="card kpi-card bg-danger text-white h-100 shadow-sm">
<div class="card-body p-3">
<div class="kpi-label text-white-50">總成本 (Cost)</div>
<div class="kpi-value">${{ "{:,.0f}".format(kpi.cost) }}</div>
<i class="fas fa-file-invoice-dollar icon-bg"></i>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card kpi-card bg-success text-white h-100 shadow-sm">
<div class="card-body p-3">
<div class="kpi-label text-white-50">毛利額 (Profit)</div>
<div class="kpi-value">${{ "{:,.0f}".format(kpi.gross_margin) }}</div>
<i class="fas fa-hand-holding-usd icon-bg"></i>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card kpi-card bg-success text-white h-100 shadow-sm"
style="background-color: #20c997 !important;">
<div class="card-body p-3">
<div class="kpi-label text-white-50">毛利率 (%)</div>
<div class="kpi-value">{{ "{:.1f}%".format(kpi.gross_margin_rate) }}</div>
<i class="fas fa-percentage icon-bg"></i>
</div>
</div>
</div>
{% endif %}
<div class="col-md-2">
<div class="card kpi-card bg-info text-white h-100 shadow-sm">
<div class="card-body p-3">
<div class="kpi-label text-white-50">總銷量 (Qty)</div>
<div class="kpi-value">{{ "{:,.0f}".format(kpi.qty) }}</div>
<i class="fas fa-boxes icon-bg"></i>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card kpi-card bg-warning text-dark h-100 shadow-sm">
<div class="card-body p-3">
<div class="kpi-label text-black-50">商品數 (SKU)</div>
<div class="kpi-value">{{ "{:,}".format(kpi.sku_count|default(kpi.count, true)) }}</div>
<i class="fas fa-tags icon-bg"></i>
</div>
</div>
</div>
</div>
<!-- V-New: ABC 分析 (Pareto Analysis) -->
{% if abc_stats %}
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm" style="background: linear-gradient(to right, #f8f9fa, #fff);">
<div class="card-body py-3">
<div class="d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-bold text-dark"><i
class="fas fa-sort-amount-down me-2 text-primary"></i>ABC 分析 (80/20 法則)</h6>
<span class="badge bg-light text-dark border ms-2">點擊類別查看詳情</span>
</div>
<div class="row mt-3 text-center">
<div class="col-md-4 border-end p-2 abc-card"
style="cursor: pointer; transition: background 0.2s;"
onclick="window.open('/abc_analysis/detail?class=A&{{ request.query_string.decode() }}', '_blank')"
onmouseover="this.style.background='#fff5f5'"
onmouseout="this.style.background='transparent'">
<h5 class="text-danger fw-bold mb-1">A 類 (核心)</h5>
<div class="small text-muted">營收佔比: {{ "{:.1f}%".format(abc_stats['A']['pct_rev']) }}
</div>
<div class="small fw-bold">{{ abc_stats['A']['count'] }} SKU ({{
"{:.1f}%".format(abc_stats['A']['pct_sku']) }})</div>
<div class="progress mt-2" style="height: 4px;">
<div class="progress-bar bg-danger" data-width="{{ abc_stats['A']['pct_rev'] }}%">
</div>
</div>
</div>
<div class="col-md-4 border-end p-2 abc-card"
style="cursor: pointer; transition: background 0.2s;"
onclick="window.open('/abc_analysis/detail?class=B&{{ request.query_string.decode() }}', '_blank')"
onmouseover="this.style.background='#fff9e6'"
onmouseout="this.style.background='transparent'">
<h5 class="text-warning fw-bold mb-1">B 類 (次要)</h5>
<div class="small text-muted">營收佔比: {{ "{:.1f}%".format(abc_stats['B']['pct_rev']) }}
</div>
<div class="small fw-bold">{{ abc_stats['B']['count'] }} SKU ({{
"{:.1f}%".format(abc_stats['B']['pct_sku']) }})</div>
<div class="progress mt-2" style="height: 4px;">
<div class="progress-bar bg-warning" data-width="{{ abc_stats['B']['pct_rev'] }}%">
</div>
</div>
</div>
<div class="col-md-4 p-2 abc-card" style="cursor: pointer; transition: background 0.2s;"
onclick="window.open('/abc_analysis/detail?class=C&{{ request.query_string.decode() }}', '_blank')"
onmouseover="this.style.background='#f0fff4'"
onmouseout="this.style.background='transparent'">
<h5 class="text-success fw-bold mb-1">C 類 (長尾)</h5>
<div class="small text-muted">營收佔比: {{ "{:.1f}%".format(abc_stats['C']['pct_rev']) }}
</div>
<div class="small fw-bold">{{ abc_stats['C']['count'] }} SKU ({{
"{:.1f}%".format(abc_stats['C']['pct_sku']) }})</div>
<div class="progress mt-2" style="height: 4px;">
<div class="progress-bar bg-success" data-width="{{ abc_stats['C']['pct_rev'] }}%">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- V-New: 年度對比 (Year-over-Year Comparison) -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="mb-0 fw-bold text-dark">
<i class="fas fa-chart-line me-2 text-info"></i>年度對比 (YoY Comparison)
</h6>
<div class="d-flex align-items-center gap-2">
<select id="yoy-year1" class="form-select form-select-sm" style="width: 100px;">
<option value="2025" selected>2025</option>
<option value="2024">2024</option>
<option value="2023">2023</option>
</select>
<span class="text-muted">vs</span>
<select id="yoy-year2" class="form-select form-select-sm" style="width: 100px;">
<option value="2026" selected>2026</option>
<option value="2025">2025</option>
<option value="2024">2024</option>
</select>
<select id="yoy-metric" class="form-select form-select-sm" style="width: 120px;">
<option value="revenue" selected>銷售金額</option>
<option value="qty">銷售數量</option>
<option value="profit">毛利金額</option>
</select>
<button class="btn btn-sm btn-outline-primary" onclick="loadYoYData()">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<!-- YoY Summary Cards -->
<div class="row mb-3" id="yoy-summary">
<div class="col-md-4">
<div class="card bg-light border-0">
<div class="card-body text-center py-3">
<small class="text-muted" id="yoy-year1-label">2024年</small>
<h4 class="mb-0 fw-bold text-secondary" id="yoy-year1-value">$0</h4>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card bg-light border-0">
<div class="card-body text-center py-3">
<small class="text-muted" id="yoy-year2-label">2025年</small>
<h4 class="mb-0 fw-bold text-primary" id="yoy-year2-value">$0</h4>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0" id="yoy-growth-card" style="background: #e8f5e9;">
<div class="card-body text-center py-3">
<small class="text-muted">成長率</small>
<h4 class="mb-0 fw-bold" id="yoy-growth-value">
<i class="fas fa-arrow-up text-success"></i> 0%
</h4>
</div>
</div>
</div>
</div>
<!-- YoY Monthly Chart -->
<div style="height: 250px;">
<canvas id="yoy-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- V-New: 廠商獲利能力排行 (Vendor Ranking) -->
{% if vendor_stats %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-industry me-2"></i>廠商獲利能力排行 (Top 100)</span>
<a href="/api/export/excel/vendor?{{ request.query_string.decode() }}"
class="btn btn-sm btn-outline-success"><i class="fas fa-file-excel me-1"></i>匯出報表</a>
</div>
<div class="card-body">
<!-- V-Opt: 限制高度並允許垂直捲動,節省版面 -->
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light" style="position: sticky; top: 0; z-index: 1;">
<tr>
<th class="text-center" style="width: 60px;">排名</th>
<th>廠商名稱</th>
<th class="text-end">總業績</th>
<th class="text-end">佔比(%)</th> <!-- V-New -->
<th class="text-end">總銷量</th> <!-- V-New -->
<th class="text-end">平均客單(ASP)</th> <!-- V-New -->
{% if cols.cost or cols.profit %}<th class="text-end">毛利額</th>{% endif %}
{% if cols.cost or cols.profit %}<th class="text-end">毛利率</th>{% endif %}
<th class="text-end">商品數 (SKU)</th>
<th class="text-end">平均單品產值</th>
</tr>
</thead>
<tbody>
{% for v in vendor_stats %}
<tr>
<td class="text-center text-muted">{{ loop.index }}</td>
<td class="fw-bold">
<a href="javascript:setFilter('vendor', '{{ v.name }}')"
class="text-decoration-none text-dark" title="點擊篩選此廠商商品">
{{ v.name }} <i
class="fas fa-filter text-muted small ms-1 opacity-50"></i>
</a>
</td>
<td class="text-end">${{ "{:,.0f}".format(v.revenue) }}</td>
<td class="text-end small text-muted">{{ "{:.1f}%".format(v.share) }}</td>
<td class="text-end">{{ "{:,.0f}".format(v.qty) }}</td>
<td class="text-end">${{ "{:,.0f}".format(v.asp) }}</td>
{% if cols.cost or cols.profit %}<td class="text-end text-success">${{
"{:,.0f}".format(v.profit) }}</td>{% endif %}
{% if cols.cost or cols.profit %}
<td class="text-end">
<span
class="badge {{ 'bg-success' if v.margin_rate >= 30 else ('bg-warning text-dark' if v.margin_rate >= 15 else 'bg-danger') }}">
{{ "{:.1f}%".format(v.margin_rate) }}
</span>
</td>
{% endif %}
<td class="text-end">{{ v.sku_count }}</td>
<td class="text-end">${{ "{:,.0f}".format(v.revenue / v.sku_count if v.sku_count
> 0 else 0) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- V-New: 商業洞察 (Business Insights) -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-primary">
<div class="card-header bg-primary text-white">
<i class="fas fa-lightbulb me-2"></i>商業洞察 (Top 3 Highlights)
</div>
<div class="card-body">
<div class="row">
<!-- 業績 Top 3 -->
<div class="col-md-4 border-end">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-primary fw-bold mb-0">🏆 業績貢獻王 (Revenue)</h6>
<button class="btn btn-sm btn-outline-primary"
onclick="showTopDetail('revenue', 'amount')">
<i class="fas fa-list me-1"></i>詳細
</button>
</div>
<small class="text-muted d-block mb-2">分類 Top 3:</small>
<ul class="list-unstyled small mb-3">
{% for item in insights.rev_cats %}
<li><span class="badge bg-primary me-2">{{ loop.index }}</span>{{ item.name }} <span
class="float-end fw-bold">${{ "{:,.0f}".format(item.value) }}</span></li>
{% endfor %}
</ul>
<small class="text-muted d-block mb-2">商品 Top 3:</small>
<ul class="list-unstyled small">
{% for item in insights.rev_prods %}
<li class="mb-1 text-truncate"><span class="badge bg-primary me-2">{{ loop.index
}}</span>{{ item.name }} <span class="float-end fw-bold">${{
"{:,.0f}".format(item.value) }}</span></li>
{% endfor %}
</ul>
</div>
<!-- 毛利 Top 3 -->
{% if cols.cost or cols.profit %}
<div class="col-md-4 border-end">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-success fw-bold mb-0">💰 獲利金雞母 (Gross Margin)</h6>
<button class="btn btn-sm btn-outline-success"
onclick="showTopDetail('margin', 'profit')">
<i class="fas fa-list me-1"></i>詳細
</button>
</div>
<small class="text-muted d-block mb-2">分類 Top 3:</small>
<ul class="list-unstyled small mb-3">
{% for item in insights.margin_cats %}
<li><span class="badge bg-success me-2">{{ loop.index }}</span>{{ item.name }} <span
class="float-end fw-bold">${{ "{:,.0f}".format(item.value) }}</span></li>
{% endfor %}
</ul>
<small class="text-muted d-block mb-2">商品 Top 3:</small>
<ul class="list-unstyled small">
{% for item in insights.margin_prods %}
<li class="mb-1 text-truncate"><span class="badge bg-success me-2">{{ loop.index
}}</span>{{ item.name }} <span class="float-end fw-bold">${{
"{:,.0f}".format(item.value) }}</span></li>
{% endfor %}
</ul>
</div>
{% else %}
<div
class="col-md-4 border-end d-flex align-items-center justify-content-center text-muted">
<div><i class="fas fa-info-circle me-2"></i>無成本/毛利資料</div>
</div>
{% endif %}
<!-- 銷量 Top 3 -->
<div class="col-md-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-info fw-bold mb-0">📦 人氣引流款 (Sales Qty)</h6>
<button class="btn btn-sm btn-outline-info"
onclick="showTopDetail('quantity', 'qty')">
<i class="fas fa-list me-1"></i>詳細
</button>
</div>
<small class="text-muted d-block mb-2">分類 Top 3:</small>
<ul class="list-unstyled small mb-3">
{% for item in insights.qty_cats %}
<li><span class="badge bg-info me-2">{{ loop.index }}</span>{{ item.name }} <span
class="float-end fw-bold">{{ "{:,.0f}".format(item.value) }}</span></li>
{% endfor %}
</ul>
<small class="text-muted d-block mb-2">商品 Top 3:</small>
<ul class="list-unstyled small">
{% for item in insights.qty_prods %}
<li class="mb-1 text-truncate"><span class="badge bg-info me-2">{{ loop.index
}}</span>{{ item.name }} <span class="float-end fw-bold">{{
"{:,.0f}".format(item.value) }}</span></li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 圖表區塊 -->
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<i class="fas fa-chart-bar me-2"></i>Top 20 熱銷排行 ({{ '銷售金額' if selected_metric == 'amount' else
'銷售數量' }})
</div>
<div class="card-body">
<div style="height: 600px; position: relative;">
<canvas id="barChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<i class="fas fa-chart-pie me-2"></i>全站類別分佈 (Top 12)
</div>
<div class="card-body">
<div style="height: 400px; position: relative;">
<canvas id="categoryChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- V-New: 板塊圖 (Treemap) -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header"><i class="fas fa-th-large me-2"></i>業績板塊分佈 (分類 -> 商品)</div>
<div class="card-body">
<div style="height: 400px;"><canvas id="treemapChart"></canvas></div>
</div>
</div>
</div>
</div>
<!-- V-New: 第二排圖表 (價格分析) -->
<div class="row mt-4">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<i class="fas fa-chart-column me-2"></i>價格帶業績貢獻 (Price Range)
</div>
<div class="card-body">
<div style="height: 350px; position: relative;">
<canvas id="priceDistChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<i class="fas fa-braille me-2"></i>價格 vs 銷量分佈 (Scatter Plot)
</div>
<div class="card-body">
<div style="height: 350px; position: relative;">
<canvas id="scatterChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- V-New: BCG 矩陣 (商品策略) -->
{% if cols.cost or cols.profit %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header"><i class="fas fa-chess-board me-2"></i>商品策略 BCG 矩陣 (波士頓矩陣)</div>
<div class="card-body">
<p class="text-muted small mb-2"><i class="fas fa-info-circle me-1"></i> X軸: 銷量 (市場份額) | Y軸: 毛利率
(獲利能力) | 十字線: 中位數閾值</p>
<div style="height: 500px;"><canvas id="bcgChart"></canvas></div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- V-New: 淡旺季熱力圖 (Seasonality) - TODO #10 -->
{% if seasonality_data %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header"><i class="fas fa-sun me-2"></i>淡旺季熱力圖 (Seasonality Heatmap) - Top 10 分類
</div>
<div class="card-body">
<div style="height: 400px;"><canvas id="seasonalityChart"></canvas></div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- V-New 2026-01-15: 行銷活動業績貢獻 (Marketing Contributions) -->
{% if marketing_data %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-bullhorn me-2"></i>行銷活動業績貢獻 (Marketing Campaign Contribution)</span>
<div class="btn-group">
<button class="btn btn-sm btn-outline-success" onclick="exportMarketingExcel('all')">
<i class="fas fa-file-excel me-1"></i>匯出全部
</button>
</div>
</div>
<div class="card-body">
<div class="row">
<!-- 折扣活動 -->
<div class="col-lg-6 mb-4">
<h6 class="text-primary fw-bold mb-3"><i class="fas fa-tags me-2"></i>折扣活動排行 (Discount
Campaigns)</h6>
<div style="height: 350px;">
<canvas id="mktDiscountChart"></canvas>
</div>
</div>
<!-- 折價券活動 -->
<div class="col-lg-6 mb-4">
<h6 class="text-success fw-bold mb-3"><i class="fas fa-ticket-alt me-2"></i>折價券活動排行
(Coupon Campaigns)</h6>
<div style="height: 350px;">
<canvas id="mktCouponChart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- V-New: 第三排圖表 (時間分析) -->
{% if cols.date %}
<div class="row mt-4">
<!-- V-Adj: 將長週期趨勢 (月/週) 放在第一排,並加大寬度 -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<i class="fas fa-calendar-alt me-2"></i>每月業績趨勢 (Monthly Trend)
</div>
<div class="card-body">
<div style="height: 300px; position: relative;">
<canvas id="monthlyChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<i class="fas fa-chart-line me-2"></i>每週業績趨勢 (全年度)
</div>
<div class="card-body">
<div style="height: 300px; position: relative;">
<canvas id="weeklyChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- V-Adj: 將短週期規律 (日/時/熱點) 放在第二排 -->
<div class="row mt-4">
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<i class="fas fa-calendar-week me-2"></i>每日業績趨勢 (週一至週日)
</div>
<div class="card-body">
<div style="height: 300px; position: relative;">
<canvas id="dowChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<i class="fas fa-clock me-2"></i>每小時業績熱點 (00:00 - 23:00)
</div>
<div class="card-body">
<div style="height: 300px; position: relative;">
<canvas id="hourlyChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<i class="fas fa-th me-2"></i>多維度熱點 (星期 x 小時) <i class="fas fa-info-circle text-muted ms-1"
data-bs-toggle="tooltip" title="此圖表用於識別每週的「黃金銷售時段」。顏色越紅代表該時段業績越好。"></i>
</div>
<div class="card-body">
<div style="height: 300px; position: relative;">
<canvas id="heatmapChart"></canvas>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- 資料表區塊 -->
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-list-ol me-2"></i>詳細數據列表 (Top 1000)</span>
</div>
<div class="card-body">
<table id="dataTable" class="table table-hover align-middle mb-0" style="width:100%">
<thead>
<tr>
<th class="text-center" style="width: 60px;">排名</th>
<th style="width: 100px;">商品ID</th>
<th style="width: 25%;">商品名稱</th>
{% if cols.brand %}<th>品牌</th>{% endif %} <!-- V-New -->
{% if cols.vendor %}<th>廠商名稱</th>{% endif %}
{% if cols.cat %}<th>商品館 (分類)</th>{% endif %}
{% if cols.qty %}<th>平均單價</th>{% endif %} <!-- V-New -->
{% if cols.cost or cols.profit %}<th>毛利率</th>{% endif %}
{% if cols.return_qty %}<th>退貨率</th>{% endif %} <!-- V-New -->
{% if cols.date %}<th>銷售月份</th>{% endif %}
{% if cols.qty %}<th class="text-end">銷售數量</th>{% endif %}
<th class="text-end">銷售金額</th>
</tr>
</thead>
<!-- V-Opt: 內容將由 DataTables AJAX 自動填入 -->
<tbody></tbody>
</table>
</div>
</div>
{% endif %}
</div>
<!-- DataTables JS -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.datatables.net/1.11.5/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.11.5/js/dataTables.bootstrap5.min.js"></script>
{% endif %}
{% if not error %}
<!-- V-New: JSON Data Block for Data Injection -->
<script id="sales-data" type="application/json">
{
"barData": {
"labels": {{ bar_data.labels | tojson }},
"values": {{ bar_data.chart_values | tojson }},
"metricLabel": {{ bar_data.metric_label | tojson }}
},
"catData": {
"labels": {{ cat_data.labels | tojson }},
"values": {{ cat_data.chart_values | tojson }}
},
"priceDistData": {
"labels": {{ price_dist_data.labels | tojson }},
"values": {{ price_dist_data.chart_values | tojson }}
},
"scatterData": {{ scatter_data | tojson }},
"bcgData": {{ bcg_data | tojson }},
"seasonalityData": {{ seasonality_data | tojson }},
"dowData": {% if cols.date %}{
"labels": {{ dow_data.labels | tojson }},
"values": {{ dow_data.chart_values | tojson }}
}{% else %}null{% endif %},
"monthlyData": {% if cols.date %}{
"labels": {{ monthly_data.labels | tojson }},
"values": {{ monthly_data.chart_values | tojson }}
}{% else %}null{% endif %},
"weeklyData": {% if cols.date %}{
"labels": {{ weekly_data.labels | tojson }},
"values": {{ weekly_data.chart_values | tojson }}
}{% else %}null{% endif %},
"hourlyData": {% if cols.date %}{
"labels": {{ hourly_data.labels | tojson }},
"values": {{ hourly_data.chart_values | tojson }}
}{% else %}null{% endif %},
"heatmapData": {% if cols.date %}{{ heatmap_data | tojson }}{% else %}null{% endif %},
"treemapData": {{ treemap_data | tojson }},
"selectedMetric": {{ selected_metric | tojson }},
"cols": {
"brand": {{ cols.brand | tojson }},
"vendor": {{ cols.vendor | tojson }},
"cat": {{ cols.cat | tojson }},
"qty": {{ cols.qty | tojson }},
"cost": {{ cols.cost | tojson }},
"profit": {{ cols.profit | tojson }},
"return_qty": {{ cols.return_qty | tojson }},
"date": {{ cols.date | tojson }}
},
"marketingData": {{ marketing_data | tojson | safe }}
}
</script>
{% endif %}
<script>
function setFilter(name, value) {
const form = document.querySelector('form[action="/sales_analysis"]');
const input = form.querySelector(`input[name="${name}"]`);
if (input) {
input.value = value;
// V-Fix (2026-01-23): 更新下拉按鈕顯示文字(不自動提交)
const btnGroup = input.closest('.btn-group');
if (btnGroup) {
const btn = btnGroup.querySelector('.dropdown-toggle');
if (btn) {
// 定義各篩選項的預設顯示文字
const defaultTexts = {
'category': '全部分類',
'brand': '全部品牌',
'vendor': '全部廠商',
'activity': '全部活動',
'payment': '全部付款方式'
};
const displayText = (value === 'all') ? (defaultTexts[name] || '全部') : value;
// 保留圖示,只更新文字
const icon = btn.querySelector('i');
if (icon) {
btn.innerHTML = '';
btn.appendChild(icon);
btn.appendChild(document.createTextNode(' ' + displayText));
} else {
btn.textContent = displayText;
}
}
}
// V-Fix (2026-01-23): 移除自動提交,改為手動點擊查詢按鈕才執行
// document.getElementById('loadingOverlay').style.display = 'flex';
// form.submit();
}
}
// V-New: 當選擇自訂日期時,清除預設的資料範圍選項(但不自動提交)
function clearDataRange() {
const form = document.querySelector('form[action="/sales_analysis"]');
const startDate = form.querySelector('input[name="start_date"]').value;
const endDate = form.querySelector('input[name="end_date"]').value;
// 如果有任一日期被填寫,就清除 data_range但不自動提交讓用戶選完兩個日期後手動點擊查詢
if (startDate || endDate) {
form.querySelector('select[name="data_range"]').value = '';
}
}
// V-New: 清除日期區間
function clearDateRange() {
// V-Updated: 清除 Flatpickr 日期選擇器的值
if (window.startDatePicker) {
window.startDatePicker.clear();
}
if (window.endDatePicker) {
window.endDatePicker.clear();
}
const form = document.querySelector('form[action="/sales_analysis"]');
form.querySelector('input[name="start_date"]').value = '';
form.querySelector('input[name="end_date"]').value = '';
document.getElementById('loadingOverlay').style.display = 'flex';
form.submit();
}
// V-New: 處理資料載入範圍改變
function handleDataRangeChange(selectElement) {
const form = document.querySelector('form[action="/sales_analysis"]');
// 當選擇資料載入範圍時,清除自訂日期(但不自動提交,等用戶按查詢按鈕)
if (selectElement.value) {
form.querySelector('input[name="start_date"]').value = '';
form.querySelector('input[name="end_date"]').value = '';
if (window.startDatePicker) {
window.startDatePicker.clear();
}
if (window.endDatePicker) {
window.endDatePicker.clear();
}
}
// V-Fix (2026-01-23): 移除自動提交,改為手動點擊查詢按鈕才執行
// document.getElementById('loadingOverlay').style.display = 'flex';
// form.submit();
}
// V-New: 下拉選單搜尋過濾功能
function filterDropdown(input) {
const filter = input.value.toLowerCase();
const items = input.closest('.dropdown-menu').querySelectorAll('li:not(:first-child)'); // 跳過搜尋框所在的 li
items.forEach(item => {
const text = item.textContent || item.innerText;
// 保留分隔線 (hr)
if (item.querySelector('hr') || text.toLowerCase().indexOf(filter) > -1) {
item.style.display = "";
} else {
item.style.display = "none";
}
});
}
// 綁定表單提交事件以顯示 Loading
document.querySelector('form[action="/sales_analysis"]').addEventListener('submit', function () {
document.getElementById('loadingOverlay').style.display = 'flex';
});
// V-New: 為字串生成穩定顏色的函式 (用於 Treemap)
function getColorForString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash % 360);
// V-Adj: 調整為較深的顏色 (亮度 45%),以確保白色文字清晰可見
return `hsla(${hue}, 70%, 45%, 0.9)`;
}
// V-New 2026-01-15: 行銷活動 Excel 匯出
function exportMarketingExcel(type) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('type', type);
window.location.href = `/daily_sales/export_marketing?${urlParams.toString()}`;
}
</script>
{% if not error %}
<script>
// V-New: Data Injection via JSON script tag to avoid syntax errors
const salesData = JSON.parse(document.getElementById('sales-data').textContent);
const barData = salesData.barData;
const catData = salesData.catData;
const priceDistData = salesData.priceDistData;
const scatterDataPoints = salesData.scatterData;
const bcgData = salesData.bcgData;
const seasonalityData = salesData.seasonalityData;
// Conditional data
const dowData = salesData.dowData;
const monthlyData = salesData.monthlyData;
const weeklyData = salesData.weeklyData;
const hourlyData = salesData.hourlyData;
const heatmapDataPoints = salesData.heatmapData;
const treemapDataPoints = salesData.treemapData;
const cols = salesData.cols;
const selectedMetric = salesData.selectedMetric;
// 1. 橫向長條圖 (Horizontal Bar Chart) - 更易讀
const ctxBar = document.getElementById('barChart').getContext('2d');
new Chart(ctxBar, {
type: 'bar',
data: {
labels: barData.labels,
datasets: [{
label: barData.metricLabel,
data: barData.values,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
},
options: {
indexAxis: 'y', // 轉為橫向
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { beginAtZero: true }
}
,
// V-New: 圖表連動 - 點擊商品長條自動搜尋該商品
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const label = barData.labels[index];
setFilter('keyword', label); // 將商品名稱帶入搜尋框
}
}
}
});
// 2. 圓餅圖 (類別佔比)
const ctxCat = document.getElementById('categoryChart').getContext('2d');
new Chart(ctxCat, {
type: 'doughnut',
data: {
labels: catData.labels,
datasets: [{
data: catData.values,
backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#C9CBCF']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const label = catData.labels[index];
// V-Fix: 允許點擊 '其他',後端已實作對應邏輯
window.location.href = `/sales_analysis?category=${encodeURIComponent(label)}&metric=${selectedMetric}`;
}
},
plugins: {
tooltip: {
callbacks: {
label: function (context) {
let label = context.label || '';
let value = context.parsed;
let total = context.dataset.data.reduce((a, b) => a + b, 0);
let percentage = Math.round((value / total) * 100) + '%';
if (label) { label += ': '; }
label += '$' + value.toLocaleString() + ' (' + percentage + ')';
return label;
}
}
}
}
}
});
// 3. 價格帶分佈圖 (Bar Chart)
const ctxPrice = document.getElementById('priceDistChart').getContext('2d');
new Chart(ctxPrice, {
type: 'bar',
data: {
labels: priceDistData.labels,
datasets: [{
label: '區間總業績 ($)',
data: priceDistData.values,
backgroundColor: 'rgba(75, 192, 192, 0.6)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } },
// V-New: 圖表連動 - 點擊價格區間自動篩選
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const label = priceDistData.labels[index];
// 解析標籤 (例如 "1,000-1,999" 或 "10,000+")
let min = '', max = '';
if (label.includes('+')) {
min = label.replace(/,/g, '').replace('+', '');
} else {
const parts = label.split('-');
if (parts.length === 2) {
min = parts[0].replace(/,/g, '');
max = parts[1].replace(/,/g, '');
}
}
setPriceFilter(min, max);
}
}
}
});
// 4. 散佈圖 (Scatter Plot)
const ctxScatter = document.getElementById('scatterChart').getContext('2d');
new Chart(ctxScatter, {
type: 'scatter',
data: {
datasets: [{
label: '商品分佈',
data: scatterDataPoints,
backgroundColor: 'rgba(255, 99, 132, 0.6)'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { title: { display: true, text: '單價 ($)' }, beginAtZero: true },
y: { title: { display: true, text: '銷售數量' }, beginAtZero: true }
},
plugins: {
tooltip: {
callbacks: {
label: function (context) {
const p = context.raw;
return p.name + ': $' + p.x + ' / ' + p.y + '個 (總額: $' + p.amt.toLocaleString() + ')';
}
}
}
}
}
});
// V-New 2026-01-15: 初始化行銷活動圖表
if (salesData.marketingData) {
const mkt = salesData.marketingData;
const currentMetric = salesData.selectedMetric || 'amount';
// 根據選取的維度決定標籤與數據 Key
const metricKey = currentMetric === 'amount' ? 'revenue' : (currentMetric === 'qty' ? 'qty' : 'profit');
const metricLabel = currentMetric === 'amount' ? '營收貢獻' : (currentMetric === 'qty' ? '銷量貢獻' : '毛利貢獻');
const prefix = currentMetric === 'qty' ? '' : '$';
// 1. 折扣活動排行
if (mkt.discount && mkt.discount.length > 0) {
const ctxMktDisc = document.getElementById('mktDiscountChart');
if (ctxMktDisc) {
new Chart(ctxMktDisc.getContext('2d'), {
type: 'bar',
data: {
labels: mkt.discount.map(i => i.name.length > 20 ? i.name.substr(0, 20) + '...' : i.name),
datasets: [{
label: metricLabel,
data: mkt.discount.map(i => i[metricKey] || 0),
backgroundColor: 'rgba(54, 162, 235, 0.7)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (ctx) { return prefix + ctx.raw.toLocaleString(); }
}
}
},
scales: { x: { beginAtZero: true } },
onClick: () => exportMarketingExcel('discount')
}
});
}
}
// 2. 折價券活動排行
if (mkt.coupon && mkt.coupon.length > 0) {
const ctxMktCoupon = document.getElementById('mktCouponChart');
if (ctxMktCoupon) {
new Chart(ctxMktCoupon.getContext('2d'), {
type: 'bar',
data: {
labels: mkt.coupon.map(i => i.name.length > 20 ? i.name.substr(0, 20) + '...' : i.name),
datasets: [{
label: metricLabel,
data: mkt.coupon.map(i => i[metricKey] || 0),
backgroundColor: 'rgba(75, 192, 192, 0.7)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (ctx) { return prefix + ctx.raw.toLocaleString(); }
}
}
},
scales: { x: { beginAtZero: true } },
onClick: () => exportMarketingExcel('coupon')
}
});
}
}
}
// 4.5 BCG 矩陣 (Scatter Chart) - V-New
if (bcgData && bcgData.datasets.length > 0) {
const ctxBcg = document.getElementById('bcgChart').getContext('2d');
// 自定義插件:繪製象限閾值線
const quadrantLinePlugin = {
id: 'quadrantLines',
beforeDraw: (chart) => {
const { ctx, chartArea: { top, bottom, left, right }, scales: { x, y } } = chart;
const xVal = x.getPixelForValue(bcgData.thresholds.x);
const yVal = y.getPixelForValue(bcgData.thresholds.y);
ctx.save();
ctx.strokeStyle = 'rgba(100, 100, 100, 0.5)';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
// 繪製垂直線 (銷量中位數)
if (xVal >= left && xVal <= right) {
ctx.beginPath();
ctx.moveTo(xVal, top);
ctx.lineTo(xVal, bottom);
ctx.stroke();
}
// 繪製水平線 (毛利中位數)
if (yVal >= top && yVal <= bottom) {
ctx.beginPath();
ctx.moveTo(left, yVal);
ctx.lineTo(right, yVal);
ctx.stroke();
}
ctx.restore();
}
};
new Chart(ctxBcg, {
type: 'scatter',
data: { datasets: bcgData.datasets },
plugins: [quadrantLinePlugin],
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { title: { display: true, text: '銷售數量 (Qty)' }, beginAtZero: true },
y: { title: { display: true, text: '毛利率 (%)' }, beginAtZero: true }
},
plugins: {
tooltip: {
callbacks: {
label: function (context) {
const p = context.raw;
return p.name + ': ' + p.y + '% / ' + p.x + '個 ($' + p.amt.toLocaleString() + ')';
}
}
}
},
// V-New: BCG 矩陣連動 - 點擊商品氣泡自動搜尋
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const datasetIndex = elements[0].datasetIndex;
const p = bcgData.datasets[datasetIndex].data[index];
if (p && p.name) {
setFilter('keyword', p.name);
}
}
}
}
});
}
// 4.6 淡旺季熱力圖 (Seasonality Chart) - V-New
if (seasonalityData) {
const ctxSeason = document.getElementById('seasonalityChart').getContext('2d');
new Chart(ctxSeason, {
type: 'bubble',
data: {
datasets: [{
label: '淡旺季分佈',
data: seasonalityData.datasets[0].data,
backgroundColor: function (context) {
const r = context.raw.r;
// 顏色越紅代表業績越高
const intensity = Math.max(0, Math.min(1, (r - 3) / 25));
const hue = (1 - intensity) * 240; // 240(藍) -> 0(紅)
return `hsla(${hue}, 70%, 50%, 0.7)`;
},
borderColor: 'rgba(0,0,0,0.1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'category',
labels: seasonalityData.xLabels,
title: { display: true, text: '月份' }
},
y: {
type: 'category',
labels: seasonalityData.yLabels,
title: { display: true, text: '商品分類' },
reverse: true // 讓排名第一的分類在最上方
}
},
plugins: {
legend: {
display: true,
labels: {
generateLabels: function () {
return [
{
text: '🔴 旺季 (業績高)',
fillStyle: 'rgba(255, 0, 0, 0.7)',
hidden: false,
index: 0
},
{
text: '🔵 淡季 (業績低)',
fillStyle: 'rgba(0, 0, 255, 0.7)',
hidden: false,
index: 1
}
];
}
}
},
tooltip: {
callbacks: {
label: function (context) {
const d = context.raw;
// V-Fix: 改善 tooltip 可讀性,使用多行顯示
return [
`📅 月份: ${d.m}`,
`🏷️ 分類: ${d.c}`,
`💰 累積業績: $${Math.round(d.v).toLocaleString()}`,
`💡 氣泡越大、顏色越紅表示業績越好`,
`👉 點擊可篩選此分類`
];
}
}
}
},
// V-Fix: onClick 應該在 options 根層級,不是 plugins 內
// V-Fix 2026-01-16: 改為連動篩選分類,而非匯出 Excel
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const d = seasonalityData.datasets[0].data[index];
// 篩選該分類
setFilter('category', d.c);
}
}
}
});
}
if (dowData) {
// 5. 星期分析圖 (Bar Chart)
const ctxDow = document.getElementById('dowChart').getContext('2d');
new Chart(ctxDow, {
type: 'bar',
data: {
labels: dowData.labels,
datasets: [{
label: '當日總業績 ($)',
data: dowData.values,
backgroundColor: 'rgba(255, 159, 64, 0.6)',
borderColor: 'rgba(255, 159, 64, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } },
// V-New: 圖表連動 - 點擊星期長條自動篩選
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
setFilter('dow', index.toString());
}
}
}
});
// 5.5 每月趨勢圖 (Bar Chart) - V-New
const ctxMonthly = document.getElementById('monthlyChart').getContext('2d');
new Chart(ctxMonthly, {
type: 'bar',
data: {
labels: monthlyData.labels,
datasets: [{
label: '月總業績 ($)',
data: monthlyData.values,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } },
// V-New: 圖表連動 - 點擊月份長條自動篩選
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const label = monthlyData.labels[index];
setFilter('month', label);
}
}
}
});
// 7. 每週趨勢圖 (Line Chart) - V-New
const ctxWeekly = document.getElementById('weeklyChart').getContext('2d');
new Chart(ctxWeekly, {
type: 'line',
data: {
labels: weeklyData.labels,
datasets: [{
label: '週總業績 ($)',
data: weeklyData.values,
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 2,
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } },
// V-New: 週趨勢圖連動 - 點擊週次自動排除其他日期範圍
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const label = weeklyData.labels[index]; // 格式: "2025-W03"
// 將 ISO 週次轉換為日期區間 (簡易版: 只顯示該週)
// TODO: 實作精確的週次到日期轉換
console.log('Clicked week:', label);
// 暫時使用月份範圍近似 (待後端支援 week 範圍篩選)
if (label && label.includes('-W')) {
const [year, week] = label.split('-W');
// 簡化處理: 顯示 alert 提示該週次
alert(`已選取: ${year}年第${week}\n未來將支援週次範圍篩選`);
}
}
}
}
});
// 6. 小時分析圖 (Line Chart)
const ctxHourly = document.getElementById('hourlyChart').getContext('2d');
new Chart(ctxHourly, {
type: 'line',
data: {
labels: hourlyData.labels,
datasets: [{
label: '時段總業績 ($)',
data: hourlyData.values,
backgroundColor: 'rgba(153, 102, 255, 0.2)',
borderColor: 'rgba(153, 102, 255, 1)',
borderWidth: 2,
fill: true,
tension: 0.3 // 平滑曲線
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } },
// V-New: 圖表連動 - 點擊小時節點自動篩選
onClick: (evt, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
setFilter('hour', index.toString());
}
}
}
});
// 8. 多維度熱力圖 (Bubble Chart 模擬 Heatmap) - V-New
const ctxHeatmap = document.getElementById('heatmapChart').getContext('2d');
new Chart(ctxHeatmap, {
type: 'bubble',
data: {
datasets: [{
label: '業績熱點',
data: heatmapDataPoints,
// V-Opt: 動態熱力著色 (藍->紅)
backgroundColor: function (context) {
const r = context.raw.r;
// 正規化半徑範圍約 3-25將其映射到 0-1
const intensity = Math.max(0, Math.min(1, (r - 3) / 22));
// HSL: 240(藍) -> 0(紅)
const hue = (1 - intensity) * 240;
return `hsla(${hue}, 70%, 50%, 0.7)`;
},
borderColor: 'rgba(0,0,0,0.1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: 10 },
scales: {
x: {
min: 0, max: 23,
title: { display: true, text: '小時 (0-23)' },
ticks: { stepSize: 2 }
},
y: {
reverse: true, // V-Opt: 反轉 Y 軸,讓週一在上方
min: 0, max: 6,
title: { display: true, text: '星期 (0=週一, 6=週日)' },
ticks: {
callback: function (value) {
const days = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'];
return days[value];
},
stepSize: 1
}
}
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (context) {
const d = context.raw;
// V-Fix: 正確顯示星期和小時,而非月份和分類
const dayNames = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'];
const dayName = dayNames[d.y] || `星期${d.y}`;
const hourStr = String(d.x).padStart(2, '0') + ':00';
return [
`📅 ${dayName}`,
`🕐 ${hourStr}`,
`💰 業績: $${Math.round(d.v).toLocaleString()}`,
`👉 點擊可篩選此時段`
];
}
}
}
},
onClick: (evt, elements, chart) => {
// V-Fix: Chart.js 的 evt 是包裝對象,不需要 preventDefault
console.log('Heatmap onClick triggered', { elements });
if (elements && elements.length > 0) {
const index = elements[0].index;
const point = heatmapDataPoints[index];
console.log('Clicked point:', point);
if (point && typeof point.y !== 'undefined' && typeof point.x !== 'undefined') {
setDowHourFilter(point.y.toString(), point.x.toString());
} else {
console.error('Invalid point data:', point);
}
} else {
console.log('No elements clicked');
}
}
}
});
}
// 9. 板塊圖 (Treemap) - V-New
const ctxTreemap = document.getElementById('treemapChart').getContext('2d');
new Chart(ctxTreemap, {
type: 'treemap',
data: {
datasets: [{
tree: treemapDataPoints,
key: 'value',
groups: ['category', 'product'], // 定義層級:先分類,再商品
spacing: 2, // V-New: 增加區塊間距
borderWidth: 0,
borderRadius: 4, // V-New: 圓角
backgroundColor: (ctx) => {
if (ctx.type !== 'data') return 'transparent';
// V-Fix: 優先使用後端傳來的顏色 (若有),確保與分類顏色一致且為深色系 (TODO 5)
if (ctx.raw._data && ctx.raw._data.color) {
return ctx.raw._data.color;
}
let keyName = '';
if (ctx.raw._data && ctx.raw._data.product) {
keyName = ctx.raw._data.product; // 商品層級:用商品名
} else if (ctx.raw._data && ctx.raw._data.category) {
keyName = ctx.raw._data.category; // 分類層級
} else if (ctx.raw.g) {
keyName = ctx.raw.g;
}
return getColorForString(keyName || 'unknown');
},
labels: {
display: true,
formatter: (ctx) => {
// 只在空間足夠時顯示文字
if (!ctx.raw) return "";
const name = ctx.raw.g === 'product' ? ctx.raw._data.product : ctx.raw.g;
const val = Math.round(ctx.raw.v).toLocaleString();
return name + '\n$' + val;
},
align: 'center',
color: 'white',
font: { size: 13, weight: 'bold' } // V-New: 加粗字體
}
}]
},
options: {
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: true } }
,
// V-New: 圖表連動 - 點擊板塊自動篩選分類
onClick: (evt, elements) => {
if (elements.length > 0) {
const data = elements[0].element.$context.raw._data;
if (data && data.category) {
setFilter('category', data.category);
}
}
}
}
});
// 3. 初始化 DataTables (分頁、搜尋、排序)
$(document).ready(function () {
// V-Opt: 動態構建 columns 陣列,避免 Jinja2 語法干擾編輯器
const tableColumns = [
{
"data": "rank",
"className": "text-center",
"render": function (data) {
// V-New: 前三名使用獎牌徽章,其他使用普通數字
if (data === 1) return '<span class="badge bg-warning text-dark" style="font-size: 0.9rem;"><i class="fas fa-trophy me-1"></i>1</span>';
if (data === 2) return '<span class="badge bg-secondary" style="font-size: 0.9rem;"><i class="fas fa-medal me-1"></i>2</span>';
if (data === 3) return '<span class="badge bg-secondary" style="font-size: 0.9rem;"><i class="fas fa-medal me-1"></i>3</span>';
return `<span class="text-muted fw-bold" style="font-size: 0.95rem;">${data}</span>`;
}
},
{
"data": "product_id",
"className": "font-monospace",
"render": function (data, type, row) {
return `<div class="d-flex align-items-center">
<code class="small me-2">${data || '-'}</code>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" onclick="copyToClipboard('${data}', this)" title="複製商品ID">
<i class="fas fa-copy" style="font-size: 0.7rem;"></i>
</button>
</div>`;
}
},
{
"data": "name",
"render": function (data, type, row) {
return `<div class="text-truncate-2 fw-semibold text-dark" title="${data}" style="font-size: 0.95rem; line-height: 1.4;">${data}</div>`;
}
}
];
if (cols.brand) tableColumns.push({
"data": "brand",
"className": "text-secondary",
"render": function (data) { return `<span style="font-size: 0.9rem;">${data || '-'}</span>`; }
});
if (cols.vendor) tableColumns.push({
"data": "vendor",
"className": "text-secondary",
"render": function (data) { return `<span style="font-size: 0.9rem;">${data || '-'}</span>`; }
});
if (cols.cat) {
tableColumns.push({
"data": "category",
"render": function (data) { return `<span class="badge bg-light text-dark border" style="font-size: 0.85rem;">${data}</span>`; }
});
}
if (cols.qty) {
tableColumns.push({
"data": "avg_price",
"className": "text-end",
"render": function (data) { return '<span style="font-size: 0.95rem; color: #495057;">$' + parseFloat(data).toLocaleString(undefined, { maximumFractionDigits: 0 }) + '</span>'; }
});
}
if (cols.cost || cols.profit) {
tableColumns.push({
"data": "margin_rate",
"className": "text-center",
"render": function (data) {
let colorClass = data >= 30 ? 'text-success' : (data < 10 ? 'text-danger' : 'text-warning');
let bgClass = data >= 30 ? 'bg-success' : (data < 10 ? 'bg-danger' : 'bg-warning');
return `<span class="badge ${bgClass} bg-opacity-10 ${colorClass}" style="font-size: 0.9rem; font-weight: 600;">${parseFloat(data).toFixed(1)}%</span>`;
}
});
}
if (cols.return_qty) {
tableColumns.push({
"data": "return_rate",
"className": "text-end",
"render": function (data) {
let color = data > 5 ? '#dc3545' : (data > 2 ? '#fd7e14' : '#6c757d');
return `<span style="font-size: 0.9rem; color: ${color};">${parseFloat(data).toFixed(1)}%</span>`;
}
});
}
if (cols.date) tableColumns.push({
"data": "month_str",
"className": "text-muted",
"render": function (data) { return `<span style="font-size: 0.85rem;">${data}</span>`; }
});
if (cols.qty) tableColumns.push({
"data": "qty",
"className": "text-end fw-semibold text-primary",
"render": function (data) { return '<span style="font-size: 1rem;">' + parseFloat(data).toLocaleString(undefined, { maximumFractionDigits: 0 }) + '</span>'; }
});
// 最後加入金額欄位
tableColumns.push({
"data": "amount",
"className": "text-end fw-bold text-danger",
"render": function (data) { return '<span style="font-size: 1.05rem;">$' + parseFloat(data).toLocaleString() + '</span>'; }
});
$('#dataTable').DataTable({
// V-Opt: 改為 AJAX 載入,大幅提升初始頁面渲染速度
"ajax": {
"url": "/api/sales_analysis/table_data?" + window.location.search.substring(1) + "&_t=" + new Date().getTime(),
"dataSrc": "data",
"cache": false, // V-Fix: 禁用快取以確保篩選條件立即生效
"error": function(xhr, error, thrown) {
console.error('DataTables AJAX Error:', error, thrown);
console.error('Response:', xhr.responseText ? xhr.responseText.substring(0, 500) : 'empty');
// 隱藏載入中覆蓋層
var overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.style.display = 'none';
alert('載入數據失敗: ' + (thrown || error));
}
},
"columns": tableColumns,
"language": { "url": "//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json" },
"pageLength": 25,
"order": [], // 取消預設排序,由後端控制
"deferRender": true, // 優化渲染效能
"processing": true // 顯示處理中提示
});
// 初始化 Bootstrap Tooltips (用於集中度說明)
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
// V-Fix: 初始化進度條寬度 (解決 CSS 語法檢查錯誤)
document.querySelectorAll('.progress-bar[data-width]').forEach(function (bar) {
bar.style.width = bar.getAttribute('data-width');
});
});
// V-New: 輔助函式 - 設定價格區間並提交
function setPriceFilter(min, max) {
const form = document.querySelector('form[action="/sales_analysis"]');
if (min) form.querySelector('input[name="min_price"]').value = min;
if (max) form.querySelector('input[name="max_price"]').value = max;
// 清空其他可能衝突的篩選以確保精確
document.getElementById('loadingOverlay').style.display = 'flex';
form.submit();
}
// V-New: 輔助函式 - 設定星期與小時並提交 (用於熱力圖)
function setDowHourFilter(dow, hour) {
const form = document.querySelector('form[action="/sales_analysis"]');
const dowInput = form.querySelector('input[name="dow"]');
const hourInput = form.querySelector('input[name="hour"]');
if (dowInput) dowInput.value = dow;
if (hourInput) hourInput.value = hour;
document.getElementById('loadingOverlay').style.display = 'flex';
form.submit();
}
// V-New: 複製文字到剪貼簿
function copyToClipboard(text, button) {
if (!text || text === '-') {
return;
}
// 使用 Clipboard API
navigator.clipboard.writeText(text).then(function () {
// 改變按鈕圖示和顏色表示成功
const icon = button.querySelector('i');
const originalClass = icon.className;
icon.className = 'fas fa-check';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-success');
// 2秒後恢復原狀
setTimeout(function () {
icon.className = originalClass;
button.classList.remove('btn-success');
button.classList.add('btn-outline-secondary');
}, 2000);
}).catch(function (err) {
console.error('複製失敗:', err);
alert('複製失敗,請手動複製');
});
}
</script>
{% endif %}
<!-- V-New: Top Detail Modal -->
<div class="modal fade" id="topDetailModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="topDetailModalTitle">詳細列表</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<button class="btn btn-sm btn-outline-secondary" id="showCategoryBtn"
onclick="toggleTopType('category')">
<i class="fas fa-layer-group me-1"></i>分類排行
</button>
<button class="btn btn-sm btn-outline-secondary ms-2" id="showProductBtn"
onclick="toggleTopType('product')">
<i class="fas fa-box me-1"></i>商品排行
</button>
</div>
<button class="btn btn-sm btn-success" onclick="exportTopDetail()">
<i class="fas fa-file-excel me-1"></i>匯出 Excel
</button>
</div>
<div id="topDetailContent">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">載入中...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let currentTopType = 'revenue';
let currentMetric = 'amount';
let currentViewType = 'product'; // 'category' or 'product'
let topDetailData = null;
// 顯示 Top 詳細資料
function showTopDetail(type, metric) {
currentTopType = type;
currentMetric = metric;
currentViewType = 'product';
// 更新 Modal 標題
const titles = {
'revenue': '🏆 業績貢獻王 - 詳細排行',
'margin': '💰 獲利金雞母 - 詳細排行',
'quantity': '📦 人氣引流款 - 詳細排行'
};
document.getElementById('topDetailModalTitle').textContent = titles[type];
// 顯示 Modal
const modal = new bootstrap.Modal(document.getElementById('topDetailModal'));
modal.show();
// 載入資料
loadTopDetail();
}
// 載入 Top 詳細資料
function loadTopDetail() {
const params = new URLSearchParams(window.location.search);
params.set('type', currentTopType);
params.set('metric', currentMetric);
params.set('view', currentViewType);
fetch(`/api/sales_analysis/top_detail?${params}`)
.then(response => response.json())
.then(data => {
topDetailData = data;
renderTopDetail(data);
})
.catch(error => {
document.getElementById('topDetailContent').innerHTML =
'<div class="alert alert-danger">載入失敗:' + error.message + '</div>';
});
}
// 渲染 Top 詳細資料
function renderTopDetail(data) {
const contentDiv = document.getElementById('topDetailContent');
// 更新按鈕狀態
document.getElementById('showCategoryBtn').classList.toggle('active', currentViewType === 'category');
document.getElementById('showProductBtn').classList.toggle('active', currentViewType === 'product');
if (!data || !data.items || data.items.length === 0) {
contentDiv.innerHTML = '<div class="alert alert-info">無資料</div>';
return;
}
let html = '<div class="table-responsive"><table class="table table-hover table-sm">';
html += '<thead class="table-light"><tr>';
html += '<th width="60">排名</th>';
if (currentViewType === 'product') {
html += '<th width="120">商品ID</th>';
html += '<th>商品名稱</th>';
html += '<th width="120">品牌</th>';
html += '<th width="120">廠商</th>';
html += '<th width="100">分類</th>';
} else {
html += '<th>分類名稱</th>';
}
if (currentMetric === 'qty') {
html += '<th width="100" class="text-end">數量</th>';
} else if (currentMetric === 'profit') {
html += '<th width="120" class="text-end">毛利金額</th>';
html += '<th width="100" class="text-end">毛利率</th>';
} else {
html += '<th width="120" class="text-end">業績金額</th>';
}
html += '</tr></thead><tbody>';
data.items.forEach((item, index) => {
html += '<tr>';
html += `<td><span class="badge bg-secondary">${index + 1}</span></td>`;
if (currentViewType === 'product') {
html += `<td>
<div class="d-flex align-items-center">
<code class="small me-2">${item.product_id || 'N/A'}</code>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" onclick="copyToClipboard('${item.product_id || ''}', this)" title="複製商品ID">
<i class="fas fa-copy" style="font-size: 0.7rem;"></i>
</button>
</div>
</td>`;
html += `<td class="small">${item.name}</td>`;
html += `<td class="small text-muted">${item.brand || '-'}</td>`;
html += `<td class="small text-muted">${item.vendor || '-'}</td>`;
html += `<td class="small text-muted">${item.category || '-'}</td>`;
} else {
html += `<td>${item.name}</td>`;
}
if (currentMetric === 'qty') {
html += `<td class="text-end fw-bold">${Number(item.value).toLocaleString()}</td>`;
} else if (currentMetric === 'profit') {
html += `<td class="text-end fw-bold text-success">$${Number(item.value).toLocaleString()}</td>`;
html += `<td class="text-end">${item.margin_rate ? item.margin_rate.toFixed(1) + '%' : '-'}</td>`;
} else {
html += `<td class="text-end fw-bold text-primary">$${Number(item.value).toLocaleString()}</td>`;
}
html += '</tr>';
});
html += '</tbody></table></div>';
contentDiv.innerHTML = html;
}
// 切換檢視類型(分類/商品)
function toggleTopType(type) {
currentViewType = type;
loadTopDetail();
}
// 匯出 Excel
function exportTopDetail() {
if (!topDetailData || !topDetailData.items || topDetailData.items.length === 0) {
alert('無資料可匯出');
return;
}
const params = new URLSearchParams(window.location.search);
params.set('type', currentTopType);
params.set('metric', currentMetric);
params.set('view', currentViewType);
window.location.href = `/api/sales_analysis/export_top_detail?${params}`;
}
// ================= V-New: Year-over-Year Comparison =================
let yoyChart = null;
function loadYoYData() {
const year1 = document.getElementById('yoy-year1').value;
const year2 = document.getElementById('yoy-year2').value;
const metric = document.getElementById('yoy-metric').value;
fetch(`/api/sales_analysis/yoy_comparison?year1=${year1}&year2=${year2}&metric=${metric}`)
.then(res => res.json())
.then(data => {
if (data.error) {
console.error('YoY API Error:', data.error);
return;
}
// Update summary cards
document.getElementById('yoy-year1-label').textContent = data.year1.label;
document.getElementById('yoy-year2-label').textContent = data.year2.label;
document.getElementById('yoy-year1-value').textContent = formatCurrency(data.year1.total);
document.getElementById('yoy-year2-value').textContent = formatCurrency(data.year2.total);
// Update growth rate
const growthEl = document.getElementById('yoy-growth-value');
const growthCard = document.getElementById('yoy-growth-card');
const isPositive = data.growth_rate >= 0;
growthEl.innerHTML = `<i class="fas ${isPositive ? 'fa-arrow-up text-success' : 'fa-arrow-down text-danger'}"></i> ${isPositive ? '+' : ''}${data.growth_rate.toFixed(1)}%`;
growthCard.style.background = isPositive ? '#e8f5e9' : '#ffebee';
// Update chart
updateYoYChart(data, year1, year2);
})
.catch(err => console.error('YoY Fetch Error:', err));
}
function formatCurrency(value) {
if (value >= 100000000) {
return `$${(value / 100000000).toFixed(2)}`;
} else if (value >= 10000) {
return `$${(value / 10000).toFixed(1)}`;
} else {
return `$${Math.round(value).toLocaleString()}`;
}
}
function updateYoYChart(data, year1, year2) {
const ctx = document.getElementById('yoy-chart').getContext('2d');
const labels = data.monthly_breakdown.map(m => m.month_label);
const year1Data = data.monthly_breakdown.map(m => m.year1_value);
const year2Data = data.monthly_breakdown.map(m => m.year2_value);
if (yoyChart) {
yoyChart.destroy();
}
yoyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: `${year1}`,
data: year1Data,
backgroundColor: 'rgba(108, 117, 125, 0.6)',
borderColor: 'rgba(108, 117, 125, 1)',
borderWidth: 1
},
{
label: `${year2}`,
data: year2Data,
backgroundColor: 'rgba(79, 70, 229, 0.6)',
borderColor: 'rgba(79, 70, 229, 1)',
borderWidth: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top'
},
tooltip: {
callbacks: {
label: function (context) {
return `${context.dataset.label}: ${formatCurrency(context.raw)}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function (value) {
return formatCurrency(value);
}
}
}
}
}
});
}
// Auto-load YoY data on page load
if (document.getElementById('yoy-chart')) {
loadYoYData();
}
</script>
<!-- Footer -->
<footer class="text-center text-muted small py-4 mt-5" style="border-top: 1px solid rgba(0,0,0,0.05);">
<p class="mb-1">&copy; 2026 WOOO TECH. 版權所有。</p>
<p class="mb-0">以 AI 科技守護家庭與事業,提供最精準的決策支持。</p>
</footer>
<!-- V-New: Flatpickr 日期選擇器 JS -->
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/zh-tw.js"></script>
<script>
// V-New: 初始化 Flatpickr 日期選擇器
document.addEventListener('DOMContentLoaded', function () {
const startDateInput = document.getElementById('start_date');
const endDateInput = document.getElementById('end_date');
// 配置選項
const flatpickrConfig = {
locale: flatpickr.l10ns['zh_tw'], // 使用繁體中文
dateFormat: 'Y-m-d', // 格式YYYY-MM-DD
allowInput: false, // 禁止手動輸入
clickOpens: true, // 點擊即開啟
monthSelectorType: 'dropdown', // 月份使用下拉選單
yearSelectorType: 'dropdown', // 年份使用下拉選單(更好的 UX
showMonths: 1,
onChange: function () {
clearDataRange(); // 清除資料範圍選項
},
onClose: function () {
// 關閉日期選擇器後不自動提交,讓用戶完成選擇後手動點擊查詢
}
};
// 初始化開始日期選擇器
const startPicker = flatpickr(startDateInput, {
...flatpickrConfig,
maxDate: endDateInput.value || null, // 開始日期不能晚於結束日期
onChange: function (selectedDates, dateStr) {
clearDataRange();
// 更新結束日期的最小日期限制
if (endPicker && dateStr) {
endPicker.set('minDate', dateStr);
}
}
});
// 初始化結束日期選擇器
const endPicker = flatpickr(endDateInput, {
...flatpickrConfig,
minDate: startDateInput.value || null, // 結束日期不能早於開始日期
onChange: function (selectedDates, dateStr) {
clearDataRange();
// 更新開始日期的最大日期限制
if (startPicker && dateStr) {
startPicker.set('maxDate', dateStr);
}
}
});
// 將 picker 實例儲存到全域,供 clearDateRange 函式使用
window.startDatePicker = startPicker;
window.endDatePicker = endPicker;
});
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>