ADR-017 Phase 3f-4:根目錄模板搬入 templates/,補 trends/login_history,移除 ChoiceLoader 根目錄 fallback,搬移 components,刪除 web/templates 下的空檔/死檔與 compose 舊模板 mount。
3166 lines
150 KiB
HTML
3166 lines
150 KiB
HTML
<!-- 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">© 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> |