1526 lines
70 KiB
HTML
1526 lines
70 KiB
HTML
{% extends 'ewoooc_base.html' %}
|
||
{% block title %}月份總表數據分析 - WOOO TECH{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<link rel="stylesheet" href="https://cdn.datatables.net/1.11.5/css/dataTables.bootstrap5.min.css">
|
||
<style>
|
||
.monthly-analysis-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
|
||
.monthly-analysis-hero {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 16px;
|
||
align-items: center;
|
||
padding: 22px;
|
||
border: 1px solid var(--momo-border-strong);
|
||
border-radius: 8px;
|
||
background:
|
||
radial-gradient(circle at 18px 18px, rgba(42, 37, 32, 0.12) 1px, transparent 1px),
|
||
linear-gradient(135deg, rgba(242, 178, 90, 0.22), rgba(255, 255, 255, 0.94) 46%, rgba(42, 37, 32, 0.06));
|
||
background-size: 18px 18px, auto;
|
||
box-shadow: var(--momo-shadow-soft);
|
||
}
|
||
|
||
.monthly-analysis-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin: 0;
|
||
color: var(--momo-text-strong);
|
||
font-family: var(--momo-font-display);
|
||
font-size: clamp(1.45rem, 2vw, 2.08rem);
|
||
font-weight: 800;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.monthly-analysis-title i {
|
||
color: var(--momo-warm-caramel) !important;
|
||
}
|
||
|
||
.monthly-version-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
border: 1px solid rgba(42, 37, 32, 0.14);
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
color: var(--momo-text-muted);
|
||
font-family: var(--momo-font-mono);
|
||
font-size: 0.78rem;
|
||
font-weight: 800;
|
||
padding: 6px 10px;
|
||
}
|
||
|
||
.card {
|
||
border: 1px solid var(--momo-border-subtle) !important;
|
||
border-radius: 8px;
|
||
box-shadow: var(--momo-shadow-soft);
|
||
margin-bottom: 1.5rem;
|
||
background: rgba(255, 255, 255, 0.84);
|
||
}
|
||
|
||
.card-header {
|
||
background: rgba(250, 247, 240, 0.88) !important;
|
||
border-bottom: 1px solid var(--momo-border-subtle);
|
||
font-weight: 700;
|
||
padding: 1.25rem;
|
||
}
|
||
|
||
.filter-section {
|
||
background:
|
||
radial-gradient(circle at 14px 14px, rgba(42, 37, 32, 0.1) 1px, transparent 1px),
|
||
linear-gradient(135deg, rgba(42, 37, 32, 0.94), rgba(68, 54, 40, 0.9));
|
||
background-size: 16px 16px, auto;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
color: white;
|
||
margin-bottom: 2rem;
|
||
box-shadow: var(--momo-shadow-medium);
|
||
}
|
||
|
||
.form-label {
|
||
font-weight: 600;
|
||
font-size: 0.85rem;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
|
||
/* 模擬業績分析頁面的 Dropdown 樣式 */
|
||
.custom-dropdown-btn {
|
||
background-color: white !important;
|
||
border: 1px solid #dee2e6 !important;
|
||
text-align: left;
|
||
position: relative;
|
||
padding: 0.75rem 1.25rem;
|
||
border-radius: 8px;
|
||
font-size: 1rem;
|
||
font-weight: 500;
|
||
color: #2c3e50;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.custom-dropdown-btn::after {
|
||
position: absolute;
|
||
right: 15px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.stat-card {
|
||
border-left: 4px solid var(--momo-warm-caramel) !important;
|
||
transition: transform 0.3s;
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-5px);
|
||
}
|
||
|
||
.kpi-value {
|
||
font-size: 2.2rem;
|
||
font-weight: 800;
|
||
color: var(--momo-text-strong);
|
||
font-family: var(--momo-font-mono);
|
||
}
|
||
|
||
.small {
|
||
font-size: 1rem !important;
|
||
}
|
||
|
||
.loading-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(255, 255, 255, 0.8);
|
||
z-index: 9999;
|
||
display: none;
|
||
justify-content: center;
|
||
align-items: center;
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
.monthly-analysis-page .table thead th,
|
||
.monthly-analysis-page .table-light th {
|
||
background: rgba(250, 247, 240, 0.96) !important;
|
||
color: var(--momo-text-muted);
|
||
font-weight: 800;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.monthly-analysis-hero {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block ewooo_content %}
|
||
<div id="loadingOverlay" class="loading-overlay">
|
||
<div class="spinner-border text-primary" role="status"></div>
|
||
</div>
|
||
|
||
<div class="monthly-analysis-page">
|
||
{% include 'components/_analysis_report_tabs.html' %}
|
||
<section class="monthly-analysis-hero">
|
||
<div>
|
||
<h1 class="monthly-analysis-title"><i class="fas fa-chart-pie me-2 text-primary"></i>月份總表數據分析</h1>
|
||
<p class="text-muted mb-0 mt-2">以資料庫 `monthly_summary_analysis` 的月結資料,檢視業績、毛利、YoY 與品牌/區域/價格帶結構。</p>
|
||
</div>
|
||
<div class="monthly-version-pill">
|
||
<i class="fas fa-database"></i>
|
||
<span>{{ system_version }}</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══════ 進階篩選器 (對標業績分析) ═══════ -->
|
||
<div class="filter-section">
|
||
<div class="row g-3">
|
||
<div class="col-md-2">
|
||
<label class="form-label"><i class="fas fa-calendar-alt me-1"></i> 年份</label>
|
||
<div class="dropdown">
|
||
<button class="btn btn-light custom-dropdown-btn dropdown-toggle" type="button" id="btnYear"
|
||
data-bs-toggle="dropdown">
|
||
選擇年份
|
||
</button>
|
||
<ul class="dropdown-menu w-100" id="listYear" style="max-height: 300px; overflow-y: auto;">
|
||
<li><a class="dropdown-item" href="#" onclick="selectFilter('year', '')">全部年份</a></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label"><i class="fas fa-calendar-day me-1"></i> 月份</label>
|
||
<div class="dropdown">
|
||
<button class="btn btn-light custom-dropdown-btn dropdown-toggle" type="button" id="btnMonth"
|
||
data-bs-toggle="dropdown">
|
||
選擇月份
|
||
</button>
|
||
<ul class="dropdown-menu w-100" id="listMonth" style="max-height: 300px; overflow-y: auto;">
|
||
<li><a class="dropdown-item" href="#" onclick="selectFilter('month', '')">全部月份</a></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label"><i class="fas fa-map-marker-alt me-1"></i> 區名稱</label>
|
||
<div class="dropdown">
|
||
<button class="btn btn-light custom-dropdown-btn dropdown-toggle" type="button" id="btnArea"
|
||
data-bs-toggle="dropdown">
|
||
所有區域
|
||
</button>
|
||
<ul class="dropdown-menu w-100" id="listArea" style="max-height: 300px; overflow-y: auto;">
|
||
<li><a class="dropdown-item" href="#" onclick="selectFilter('area', '')">所有區域</a></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label"><i class="fas fa-truck me-1"></i> 廠商名稱</label>
|
||
<div class="dropdown">
|
||
<button class="btn btn-light custom-dropdown-btn dropdown-toggle" type="button" id="btnVendor"
|
||
data-bs-toggle="dropdown">
|
||
所有及廠商
|
||
</button>
|
||
<ul class="dropdown-menu w-100" id="listVendor" style="max-height: 300px; overflow-y: auto;">
|
||
<li class="px-2 py-1"><input type="text" class="form-control form-control-sm"
|
||
placeholder="搜尋..." onkeyup="filterList(this)"></li>
|
||
<li><a class="dropdown-item" href="#" onclick="selectFilter('vendor', '')">所有廠商</a></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label"><i class="fas fa-exchange-alt me-1"></i> 借採轉</label>
|
||
<div class="dropdown">
|
||
<button class="btn btn-light custom-dropdown-btn dropdown-toggle" type="button" id="btnTrade"
|
||
data-bs-toggle="dropdown">
|
||
所有類別
|
||
</button>
|
||
<ul class="dropdown-menu w-100" id="listTrade">
|
||
<li><a class="dropdown-item" href="#" onclick="selectFilter('trade', '')">所有類別</a></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-1 d-flex align-items-end">
|
||
<button class="btn btn-warning w-100 fw-bold" onclick="fetchData()">
|
||
<i class="fas fa-sync-alt"></i> 刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════ 商業洞察 (Top 3 Highlights) ═══════ -->
|
||
<div class="row g-3 mb-4" id="highlightsRow" style="display: none;">
|
||
<div class="col-md-4">
|
||
<div class="card h-100 border-0 shadow-sm overflow-hidden">
|
||
<div class="card-header bg-primary text-white py-2">
|
||
<h6 class="mb-0 small fw-bold"><i class="fas fa-trophy me-1"></i> 業績貢獻王 (Top 3 Brands)</h6>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<table class="table table-sm table-hover mb-0">
|
||
<tbody id="revHighlightsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card h-100 border-0 shadow-sm overflow-hidden">
|
||
<div class="card-header bg-success text-white py-2">
|
||
<h6 class="mb-0 small fw-bold"><i class="fas fa-coins me-1"></i> 獲利金雞母 (Top 3 Brands)</h6>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<table class="table table-sm table-hover mb-0">
|
||
<tbody id="profitHighlightsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card h-100 border-0 shadow-sm overflow-hidden">
|
||
<div class="card-header bg-warning text-dark py-2">
|
||
<h6 class="mb-0 small fw-bold"><i class="fas fa-fire me-1"></i> 人氣引流款 (Top 3 Brands)</h6>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<table class="table table-sm table-hover mb-0">
|
||
<tbody id="volHighlightsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════ 年度對比趨勢 (YoY Timeline) ═══════ -->
|
||
<div class="row mb-4">
|
||
<div class="col-12">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-history me-2 text-primary"></i>年度 YoY 業績對比分析 (本期 vs
|
||
去年同期)</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="yoyTrendChart" style="height: 400px;"></div>
|
||
<div class="table-responsive mt-3">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="yoyTrendChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════ 廠商排行與類別分佈 ═══════ -->
|
||
<div class="row mb-4">
|
||
<div class="col-lg-12">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-building me-2 text-primary"></i>Top 50 廠商獲利能力排行</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="vendorRankingChart" style="height: 900px;"></div>
|
||
<div class="table-responsive mt-3">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="vendorRankingChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mb-4">
|
||
<div class="col-lg-6">
|
||
<div class="card border-0 shadow-sm h-100">
|
||
<div class="card-header">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-chart-pie me-2 text-primary"></i>全站類別業績分佈 (Top 12)
|
||
</h6>
|
||
</div>
|
||
<div class="card-body text-center">
|
||
<div id="divisionDistChart" style="height: 350px;"></div>
|
||
<div id="divisionDistChartTable" class="mt-2" style="max-height: 200px; overflow-y: auto;">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="divisionDistChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-6">
|
||
<div class="card border-0 shadow-sm h-100">
|
||
<div class="card-header">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-tags me-2 text-primary"></i>價格帶業績貢獻比例</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="priceRangeChart" style="height: 350px;"></div>
|
||
<div class="table-responsive mt-2">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="priceRangeChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 柱狀折線對比圖 (主圖) -->
|
||
<div class="row">
|
||
<div class="col-lg-12">
|
||
<div class="card mb-4">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-chart-line me-2 text-primary"></i>年度月份業績對比趨勢</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="compareChart" style="height: 400px;"></div>
|
||
<div id="compareChartDataTable" class="mt-2">
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="compareChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mb-4">
|
||
<div class="col-lg-6">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-th me-2 text-primary"></i>品牌策略 BCG 矩陣 (銷量 vs 毛利率)</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="bcgMatrixChart" style="height: 400px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-6">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-braille me-2 text-primary"></i>價格 vs 銷售量分佈散佈圖</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="priceVolumeScatterChart" style="height: 400px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mb-4">
|
||
<div class="col-lg-12">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-th-large me-2 text-primary"></i>分類與月份淡旺季業績熱力圖</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="seasonalityHeatmapChart" style="height: 400px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mb-4">
|
||
<div class="col-lg-12">
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-header">
|
||
<h6 class="mb-0 fw-bold"><i class="fas fa-map me-2 text-primary"></i>區名稱全站銷售排行 (Area Ranking)
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="areaRankingChart" style="height: 400px;"></div>
|
||
<div class="table-responsive mt-3">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="areaRankingChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 專屬對比圖 (特定區域) -->
|
||
<div class="row">
|
||
<div class="col-lg-12">
|
||
<div class="card mb-4 border-info shadow-sm">
|
||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0 fw-bold text-info"><i class="fas fa-layer-group me-2"></i>開架保養 & 臉部清潔 (合併業績)
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="specialChart" style="height: 400px;"></div>
|
||
<div id="specialChartDataTable" class="mt-2">
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="specialChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 身體保養專屬對比圖 -->
|
||
<div class="row">
|
||
<div class="col-lg-12">
|
||
<div class="card mb-4 border-success shadow-sm">
|
||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0 fw-bold text-success"><i class="fas fa-magic me-2"></i>身體保養 (年度業績分析)</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="bodyCareChart" style="height: 400px;"></div>
|
||
<div id="bodyCareChartDataTable" class="mt-2">
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="bodyCareChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 彩妝/指彩 & 精油擴香 專屬對比圖 -->
|
||
<div class="row">
|
||
<div class="col-lg-12">
|
||
<div class="card mb-4 border-warning shadow-sm">
|
||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0 fw-bold text-warning"><i class="fas fa-palette me-2"></i>彩妝/指彩 & 精油擴香 (合併業績)
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="makeupFragranceChart" style="height: 400px;"></div>
|
||
<div id="makeupFragranceChartDataTable" class="mt-2">
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="makeupFragranceChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 私密保養 & 嬰幼洗沐 專屬對比圖 -->
|
||
<div class="row">
|
||
<div class="col-lg-12">
|
||
<div class="card mb-4 border-danger shadow-sm">
|
||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0 fw-bold text-danger"><i class="fas fa-heartbeat me-2"></i>私密保養 & 嬰幼洗沐 (合併業績)
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="privacyInfantChart" style="height: 400px;"></div>
|
||
<div id="privacyInfantChartDataTable" class="mt-2">
|
||
<div class="table-responsive">
|
||
<table class="table table-bordered table-sm text-center mb-0 small"
|
||
style="border-color: #dee2e6;">
|
||
<tbody id="privacyInfantChartTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 數據列表 -->
|
||
<div class="card shadow-sm mb-5">
|
||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||
<h5 class="card-title mb-0 fw-bold"><i class="fas fa-list me-2"></i>資料明細</h5>
|
||
<a href="/system_settings" class="btn btn-sm btn-outline-primary"><i
|
||
class="fas fa-plus me-1"></i>匯入數據</a>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table id="summaryTable" class="table table-hover align-middle" style="width:100%">
|
||
<thead class="bg-light text-secondary small">
|
||
<tr>
|
||
<th>年/月</th>
|
||
<th>處別</th>
|
||
<th>區名稱</th>
|
||
<th>PM</th>
|
||
<th>品牌</th>
|
||
<th>廠商</th>
|
||
<th>交易</th>
|
||
<th>銷售額(本月)</th>
|
||
<th>YoY</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script src="https://code.jquery.com/jquery-3.7.1.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>
|
||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||
<script>
|
||
let table;
|
||
let compareChart = echarts.init(document.getElementById('compareChart'));
|
||
let yoyTrendChart = echarts.init(document.getElementById('yoyTrendChart'));
|
||
let vendorRankingChart = echarts.init(document.getElementById('vendorRankingChart'));
|
||
let divisionDistChart = echarts.init(document.getElementById('divisionDistChart'));
|
||
let priceRangeChart = echarts.init(document.getElementById('priceRangeChart'));
|
||
let bcgMatrixChart = echarts.init(document.getElementById('bcgMatrixChart'));
|
||
let priceVolumeScatterChart = echarts.init(document.getElementById('priceVolumeScatterChart'));
|
||
let seasonalityHeatmapChart = echarts.init(document.getElementById('seasonalityHeatmapChart'));
|
||
let areaRankingChart = echarts.init(document.getElementById('areaRankingChart'));
|
||
|
||
let specialChart = echarts.init(document.getElementById('specialChart'));
|
||
let bodyCareChart = echarts.init(document.getElementById('bodyCareChart'));
|
||
let makeupFragranceChart = echarts.init(document.getElementById('makeupFragranceChart'));
|
||
let privacyInfantChart = echarts.init(document.getElementById('privacyInfantChart'));
|
||
let currentFilters = {
|
||
year: '', month: '', area: '', vendor: '', trade: ''
|
||
};
|
||
|
||
$(document).ready(function () {
|
||
table = $('#summaryTable').DataTable({
|
||
language: { url: "//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json" },
|
||
pageLength: 20,
|
||
order: [[0, 'desc'], [7, 'desc']],
|
||
columnDefs: [
|
||
{ targets: [7], className: 'text-end fw-bold' },
|
||
{ targets: 8, className: 'text-center' }
|
||
]
|
||
});
|
||
|
||
window.addEventListener('resize', () => {
|
||
compareChart.resize();
|
||
yoyTrendChart.resize();
|
||
vendorRankingChart.resize();
|
||
divisionDistChart.resize();
|
||
priceRangeChart.resize();
|
||
bcgMatrixChart.resize();
|
||
priceVolumeScatterChart.resize();
|
||
seasonalityHeatmapChart.resize();
|
||
areaRankingChart.resize();
|
||
specialChart.resize();
|
||
bodyCareChart.resize();
|
||
makeupFragranceChart.resize();
|
||
privacyInfantChart.resize();
|
||
});
|
||
|
||
fetchData();
|
||
});
|
||
|
||
function fetchData() {
|
||
$('#loadingOverlay').css('display', 'flex');
|
||
|
||
let url = `/api/monthly_summary_data?limit=2000`;
|
||
if (currentFilters.year) url += `&year=${currentFilters.year}`;
|
||
if (currentFilters.month) url += `&month=${currentFilters.month}`;
|
||
if (currentFilters.area) url += `&area_name=${encodeURIComponent(currentFilters.area)}`;
|
||
if (currentFilters.vendor) url += `&vendor=${encodeURIComponent(currentFilters.vendor)}`;
|
||
if (currentFilters.trade) url += `&trade_type=${encodeURIComponent(currentFilters.trade)}`;
|
||
|
||
fetch(url)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
console.log('API Data:', data);
|
||
updateUI(data);
|
||
updateFilters(data.filters);
|
||
}
|
||
$('#loadingOverlay').hide();
|
||
})
|
||
.catch(err => {
|
||
console.error(err);
|
||
$('#loadingOverlay').hide();
|
||
});
|
||
|
||
fetchSpecialChartData();
|
||
}
|
||
|
||
function fetchSpecialChartData() {
|
||
// 固定抓取 開架保養 與 臉部清潔
|
||
let url1 = `/api/monthly_summary_data?area_name=${encodeURIComponent('開架保養,臉部清潔')}&limit=1`;
|
||
fetch(url1)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
renderExcelChart(specialChart, 'specialChartTableBody', data.trend);
|
||
}
|
||
});
|
||
|
||
// 固定抓取 身體保養
|
||
let url2 = `/api/monthly_summary_data?area_name=${encodeURIComponent('身體保養')}&limit=1`;
|
||
fetch(url2)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
renderExcelChart(bodyCareChart, 'bodyCareChartTableBody', data.trend);
|
||
}
|
||
});
|
||
|
||
// 固定抓取 彩妝/指彩 與 精油擴香
|
||
let url3 = `/api/monthly_summary_data?area_name=${encodeURIComponent('彩妝/指彩,精油擴香')}&limit=1`;
|
||
fetch(url3)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
renderExcelChart(makeupFragranceChart, 'makeupFragranceChartTableBody', data.trend);
|
||
}
|
||
});
|
||
|
||
// 固定抓取 私密保養 與 嬰幼洗沐
|
||
let url4 = `/api/monthly_summary_data?area_name=${encodeURIComponent('私密保養,嬰幼洗沐')}&limit=1`;
|
||
fetch(url4)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
renderExcelChart(privacyInfantChart, 'privacyInfantChartTableBody', data.trend);
|
||
}
|
||
});
|
||
}
|
||
|
||
function selectFilter(type, value, recordText) {
|
||
currentFilters[type] = value;
|
||
const btnMap = {
|
||
year: 'btnYear', month: 'btnMonth', area: 'btnArea', vendor: 'btnVendor', trade: 'btnTrade'
|
||
};
|
||
const defaultMap = {
|
||
year: '選擇年份', month: '選擇月份', area: '所有區域', vendor: '所有廠商', trade: '所有類別'
|
||
};
|
||
|
||
$(`#${btnMap[type]}`).text(value || defaultMap[type]);
|
||
fetchData();
|
||
}
|
||
|
||
function updateUI(data) {
|
||
// KPI 更新
|
||
$('#totalRows').text(data.total_rows.toLocaleString());
|
||
$('#totalMonths').text(data.total_months);
|
||
|
||
const k = data.kpis;
|
||
$('#kpiSales').text('$' + k.sales.toLocaleString());
|
||
$('#kpiProfit').text('$' + k.profit.toLocaleString());
|
||
$('#kpiMargin').text(k.margin);
|
||
$('#kpiVol').text(k.vol.toLocaleString());
|
||
|
||
if (k.sales_yoa > 0) {
|
||
const yoy = ((k.sales - k.sales_yoa) / k.sales_yoa * 100).toFixed(1);
|
||
const cls = yoy >= 0 ? 'text-danger' : 'text-success';
|
||
$('#kpiSalesYoY').html(`<span class="${cls} fw-bold"><i class="fas fa-caret-up"></i> ${yoy}%</span> vs 去年同期`);
|
||
} else {
|
||
$('#kpiSalesYoY').text('-');
|
||
}
|
||
|
||
// 表格更新
|
||
table.clear();
|
||
data.rows.forEach(row => {
|
||
const sales = row.sales_amt_curr || 0;
|
||
const prevYear = row.sales_amt_yoa || 0;
|
||
let yoyHtml = '-';
|
||
if (prevYear > 0) {
|
||
const yoy = ((sales - prevYear) / prevYear * 100).toFixed(1);
|
||
const cls = yoy >= 0 ? 'text-danger' : 'text-success';
|
||
yoyHtml = `<span class="${cls} fw-bold">${yoy > 0 ? '+' : ''}${yoy}%</span>`;
|
||
}
|
||
|
||
table.row.add([
|
||
`${row.year}/${row.month}`,
|
||
row.division,
|
||
row.area_name || '-',
|
||
row.pm_name || '-',
|
||
`<span class="text-primary fw-600">${row.brand_name}</span>`,
|
||
`<span class="small text-muted">${row.vendor_name}</span>`,
|
||
`<span class="badge bg-light text-dark border">${row.trade_type || '-'}</span>`,
|
||
sales.toLocaleString(),
|
||
yoyHtml
|
||
]);
|
||
});
|
||
table.draw();
|
||
|
||
// 主圖表更新
|
||
renderExcelChart(compareChart, 'compareChartTableBody', data.trend);
|
||
|
||
// Phase 17: 更新新增的進階圖表
|
||
updateHighlights(data.highlights);
|
||
renderYoYTrendChart(yoyTrendChart, 'yoyTrendChartTableBody', data.yoy_trend);
|
||
renderVendorRankingChart(vendorRankingChart, 'vendorRankingChartTableBody', data.vendor_ranking);
|
||
renderDivisionDistChart(divisionDistChart, 'divisionDistChartTableBody', data.division_dist);
|
||
renderPriceRangeChart(priceRangeChart, 'priceRangeChartTableBody', data.price_contribution);
|
||
renderBCGMatrixChart(bcgMatrixChart, data.bcg_data);
|
||
renderScatterChart(priceVolumeScatterChart, data.bcg_data); // Reuse BCG data for demo scatter
|
||
renderSeasonalityHeatmap(seasonalityHeatmapChart, data.heatmap_data);
|
||
renderAreaRankingChart(areaRankingChart, 'areaRankingChartTableBody', data.area_ranking);
|
||
}
|
||
|
||
function updateFilters(f) {
|
||
const populate = (id, list, type) => {
|
||
const el = $(id);
|
||
if (el.children().length > 1) return; // 已載入則不重複載入
|
||
list.forEach(v => {
|
||
el.append(`<li><a class="dropdown-item" href="#" onclick="selectFilter('${type}', '${v}')">${v}</a></li>`);
|
||
});
|
||
};
|
||
|
||
populate('#listYear', f.years, 'year');
|
||
populate('#listMonth', f.months.map(String).sort((a, b) => a - b), 'month');
|
||
populate('#listArea', f.areas, 'area');
|
||
populate('#listTrade', f.trades, 'trade');
|
||
populate('#listVendor', f.vendors, 'vendor');
|
||
}
|
||
|
||
function renderExcelChart(chartInstance, tableBodyId, trend) {
|
||
// 解析趨勢數據:今/明兩年對比
|
||
const years = [...new Set(trend.map(t => t.date.split('/')[0]))].sort().reverse();
|
||
const currYear = years[0] || new Date().getFullYear().toString();
|
||
const prevYear = (parseInt(currYear) - 1).toString();
|
||
|
||
const months = Array.from({ length: 12 }, (_, i) => (i + 1).toString());
|
||
const currData = new Array(12).fill(0);
|
||
const prevData = new Array(12).fill(0);
|
||
const yoyData = new Array(12).fill(0);
|
||
|
||
trend.forEach(t => {
|
||
const [y, m] = t.date.split('/');
|
||
if (y === currYear) currData[parseInt(m) - 1] = t.sales;
|
||
if (y === prevYear) prevData[parseInt(m) - 1] = t.sales;
|
||
});
|
||
|
||
// 計算 YoY
|
||
for (let i = 0; i < 12; i++) {
|
||
if (prevData[i] > 0) {
|
||
yoyData[i] = parseFloat(((currData[i] - prevData[i]) / prevData[i] * 100).toFixed(1));
|
||
}
|
||
}
|
||
|
||
// 更新底標數據表格 (Excel Style)
|
||
let tableHtml = `
|
||
<tr>
|
||
<td style="width: 100px; background: #f8f9fa; font-weight: bold;">月份</td>
|
||
${months.map(m => `<td style="font-weight: bold; background: #f8f9fa; font-size: 1rem;">${m}</td>`).join('')}
|
||
</tr>
|
||
<tr>
|
||
<td class="text-start ps-2" style="font-size: 1rem;"><span style="display:inline-block; width:12px; height:12px; background:#6366f1; margin-right:5px;"></span>${currYear}</td>
|
||
${currData.map(v => `<td style="font-size: 1rem;">${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}
|
||
</tr>
|
||
<tr>
|
||
<td class="text-start ps-2" style="font-size: 1rem;"><span style="display:inline-block; width:12px; height:12px; background:#10b981; margin-right:5px;"></span>${prevYear}</td>
|
||
${prevData.map(v => `<td style="font-size: 1rem;">${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}
|
||
</tr>
|
||
<tr>
|
||
<td class="text-start ps-2" style="font-size: 1rem;"><i class="fas fa-minus" style="color:#94a3b8; margin-right:5px;"></i>YOY</td>
|
||
${yoyData.map((v, i) => `<td style="font-size: 1rem;">${(currData[i] > 0 || prevData[i] > 0) ? v + '%' : '-'}</td>`).join('')}
|
||
</tr>
|
||
`;
|
||
$(`#${tableBodyId}`).html(tableHtml);
|
||
|
||
const option = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' }
|
||
},
|
||
legend: {
|
||
data: [`${currYear}`, `${prevYear}`, 'YOY'],
|
||
bottom: 0,
|
||
show: false
|
||
},
|
||
grid: { left: '80', right: '50', top: '50', bottom: '20' },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: months,
|
||
axisLabel: { show: false },
|
||
axisTick: { show: true }
|
||
},
|
||
yAxis: [
|
||
{
|
||
type: 'value',
|
||
name: '業績',
|
||
axisLabel: { formatter: (v) => v.toLocaleString() },
|
||
splitLine: { lineStyle: { type: 'dashed' } }
|
||
},
|
||
{
|
||
type: 'value',
|
||
name: 'YoY',
|
||
axisLabel: { formatter: '{value}%' },
|
||
splitLine: { show: false }
|
||
}
|
||
],
|
||
series: [
|
||
{
|
||
name: `${currYear}`,
|
||
type: 'bar',
|
||
data: currData,
|
||
itemStyle: { color: '#6366f1' },
|
||
barMaxWidth: 20,
|
||
label: { show: true, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: (p) => p.value ? (p.value / 10000).toFixed(0) + '萬' : '' }
|
||
},
|
||
{
|
||
name: `${prevYear}`,
|
||
type: 'bar',
|
||
data: prevData,
|
||
itemStyle: { color: '#10b981' },
|
||
barMaxWidth: 20,
|
||
label: { show: true, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: (p) => p.value ? (p.value / 10000).toFixed(0) + '萬' : '' }
|
||
},
|
||
{
|
||
name: 'YOY',
|
||
type: 'line',
|
||
yAxisIndex: 1,
|
||
data: yoyData,
|
||
smooth: true,
|
||
itemStyle: { color: '#94a3b8' },
|
||
lineStyle: { width: 2 },
|
||
symbol: 'circle', symbolSize: 6,
|
||
label: {
|
||
show: true,
|
||
position: 'bottom',
|
||
fontSize: 16,
|
||
fontWeight: 'bold',
|
||
formatter: (p) => p.value ? p.value + '%' : ''
|
||
}
|
||
}
|
||
]
|
||
};
|
||
chartInstance.setOption(option);
|
||
}
|
||
|
||
function filterList(input) {
|
||
const filter = input.value.toLowerCase();
|
||
const list = input.closest('ul').querySelectorAll('li:not(:first-child)');
|
||
list.forEach(li => {
|
||
const text = li.textContent || li.innerText;
|
||
li.style.display = text.toLowerCase().indexOf(filter) > -1 ? "" : "none";
|
||
});
|
||
}
|
||
|
||
// Phase 17: 渲染函式實作
|
||
|
||
function updateHighlights(highlights) {
|
||
if (!highlights || (!highlights.rev_top.length && !highlights.profit_top.length)) {
|
||
$('#highlightsRow').hide();
|
||
return;
|
||
}
|
||
$('#highlightsRow').show();
|
||
|
||
const renderTable = (id, list, prefix = '') => {
|
||
const tbody = $(`#${id}`);
|
||
tbody.empty();
|
||
list.forEach((item, idx) => {
|
||
tbody.append(`
|
||
<tr>
|
||
<td class="ps-3"><span class="badge bg-secondary rounded-pill me-2">${idx + 1}</span>${item.name}</td>
|
||
<td class="text-end pe-3 fw-bold">${prefix}${item.value.toLocaleString()}</td>
|
||
</tr>
|
||
`);
|
||
});
|
||
};
|
||
|
||
renderTable('revHighlightsBody', highlights.rev_top, '$');
|
||
renderTable('profitHighlightsBody', highlights.profit_top, '$');
|
||
renderTable('volHighlightsBody', highlights.vol_top, '');
|
||
}
|
||
|
||
function renderYoYTrendChart(chart, tableId, data) {
|
||
// 邏輯類似 renderExcelChart 但專門處理後端傳來的 yoy_trend 格式
|
||
if (!data || !data.length) return;
|
||
|
||
const dates = data.map(d => d.date.split('/')[1] + '月');
|
||
const currData = data.map(d => d.curr);
|
||
const yoaData = data.map(d => d.yoa);
|
||
|
||
// 產生表格
|
||
const years = [...new Set(data.map(d => d.date.split('/')[0]))];
|
||
const currYearStr = years[0] || '本期';
|
||
const prevYearStr = '去年同期';
|
||
|
||
let tableHtml = `
|
||
<tr>
|
||
<td style="width: 100px; background: #f8f9fa; font-weight: bold;">月份</td>
|
||
${dates.map(m => `<td style="font-weight: bold; background: #f8f9fa;">${m}</td>`).join('')}
|
||
</tr>
|
||
<tr>
|
||
<td class="text-start ps-2"><span style="display:inline-block; width:12px; height:12px; background:#6366f1; margin-right:5px;"></span>${currYearStr}</td>
|
||
${currData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}
|
||
</tr>
|
||
<tr>
|
||
<td class="text-start ps-2"><span style="display:inline-block; width:12px; height:12px; background:#d1d5db; margin-right:5px;"></span>${prevYearStr}</td>
|
||
${yoaData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}
|
||
</tr>
|
||
`;
|
||
$(`#${tableId}`).html(tableHtml);
|
||
|
||
const option = {
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { left: '60', right: '30', top: '40', bottom: '20' },
|
||
legend: { data: [currYearStr, prevYearStr], bottom: 0 },
|
||
xAxis: { type: 'category', data: dates },
|
||
yAxis: { type: 'value', axisLabel: { formatter: v => (v / 10000).toFixed(0) + '萬' } },
|
||
series: [
|
||
{
|
||
name: currYearStr, type: 'line', data: currData,
|
||
itemStyle: { color: '#6366f1' }, lineStyle: { width: 3 }, showSymbol: false, areaStyle: { opacity: 0.1 }
|
||
},
|
||
{
|
||
name: prevYearStr, type: 'line', data: yoaData,
|
||
itemStyle: { color: '#d1d5db' }, lineStyle: { type: 'dashed' }, showSymbol: false
|
||
}
|
||
]
|
||
};
|
||
chart.setOption(option);
|
||
}
|
||
|
||
function renderDivisionDistChart(chart, tableId, data) {
|
||
if (!data || !data.length) return;
|
||
|
||
// 計算各年度總業績
|
||
const total24 = data.reduce((sum, d) => sum + (d.sales_2024 || 0), 0);
|
||
const total25 = data.reduce((sum, d) => sum + (d.sales_2025 || 0), 0);
|
||
|
||
// 準備圓餅圖資料
|
||
const pieData2024 = data.map(d => ({
|
||
name: d.name,
|
||
value: d.sales_2024 || 0
|
||
})).filter(d => d.value > 0);
|
||
|
||
const pieData2025 = data.map(d => ({
|
||
name: d.name,
|
||
value: d.sales_2025 || 0
|
||
})).filter(d => d.value > 0);
|
||
|
||
const option = {
|
||
title: [
|
||
{ text: '2024 年', left: '23%', top: '5%', textAlign: 'center', textStyle: { fontSize: 16, fontWeight: 'bold', color: '#ea580c' } },
|
||
{ text: '2025 年', left: '73%', top: '5%', textAlign: 'center', textStyle: { fontSize: 16, fontWeight: 'bold', color: '#0284c7' } }
|
||
],
|
||
tooltip: {
|
||
trigger: 'item',
|
||
formatter: function (p) {
|
||
return `<strong>${p.name}</strong><br/>業績: ${(p.value / 10000).toFixed(0)}萬<br/>佔比: ${p.percent.toFixed(1)}%`;
|
||
}
|
||
},
|
||
legend: {
|
||
type: 'scroll',
|
||
orient: 'horizontal',
|
||
bottom: 0,
|
||
textStyle: { fontSize: 12, fontWeight: 'bold' }
|
||
},
|
||
series: [
|
||
{
|
||
name: '2024 年',
|
||
type: 'pie',
|
||
radius: ['30%', '60%'],
|
||
center: ['25%', '55%'],
|
||
data: pieData2024,
|
||
itemStyle: {
|
||
color: function (params) {
|
||
const colors = ['#f97316', '#ea580c', '#c2410c', '#fb923c', '#fdba74', '#fed7aa', '#dc2626', '#ef4444', '#f87171', '#fca5a5', '#b91c1c', '#991b1b'];
|
||
return colors[params.dataIndex % colors.length];
|
||
}
|
||
},
|
||
label: {
|
||
show: true,
|
||
fontSize: 11,
|
||
fontWeight: 'bold',
|
||
formatter: p => `${p.name}\n${(p.value / 10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%`
|
||
},
|
||
emphasis: {
|
||
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
||
}
|
||
},
|
||
{
|
||
name: '2025 年',
|
||
type: 'pie',
|
||
radius: ['30%', '60%'],
|
||
center: ['75%', '55%'],
|
||
data: pieData2025,
|
||
itemStyle: {
|
||
color: function (params) {
|
||
const colors = ['#0ea5e9', '#0284c7', '#0369a1', '#38bdf8', '#7dd3fc', '#bae6fd', '#14b8a6', '#0d9488', '#2dd4bf', '#5eead4', '#0f766e', '#115e59'];
|
||
return colors[params.dataIndex % colors.length];
|
||
}
|
||
},
|
||
label: {
|
||
show: true,
|
||
fontSize: 11,
|
||
fontWeight: 'bold',
|
||
formatter: p => `${p.name}\n${(p.value / 10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%`
|
||
},
|
||
emphasis: {
|
||
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
||
}
|
||
}
|
||
]
|
||
};
|
||
chart.setOption(option);
|
||
}
|
||
|
||
function renderPriceRangeChart(chart, tableId, data) {
|
||
if (!data || !data.length) return;
|
||
|
||
// 定義價格帶順序
|
||
const order = ['0-499', '500-999', '1,000-1,999', '2,000-4,999', '5,000-9,999', '10,000+'];
|
||
data.sort((a, b) => order.indexOf(a.range) - order.indexOf(b.range));
|
||
|
||
// 計算各年度總銷售額,用於計算佔比
|
||
const totalSales2024 = data.reduce((sum, item) => sum + (item.sales_2024 || 0), 0);
|
||
const totalSales2025 = data.reduce((sum, item) => sum + (item.sales_2025 || 0), 0);
|
||
|
||
const option = {
|
||
title: { text: '', left: 'center' },
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
formatter: function (params) {
|
||
let res = params[0].name + '<br/>';
|
||
params.forEach(param => {
|
||
const val = param.value || 0;
|
||
const total = param.seriesName.includes('2024') ? totalSales2024 : totalSales2025;
|
||
const pct = total > 0 ? (val / total * 100).toFixed(1) + '%' : '0.0%';
|
||
res += `${param.marker} ${param.seriesName}: ${val.toLocaleString()} (${pct})<br/>`;
|
||
});
|
||
return res;
|
||
}
|
||
},
|
||
legend: { data: ['2024 銷售', '2025 銷售'], bottom: 0, textStyle: { fontSize: 13, fontWeight: 'bold' } },
|
||
grid: { left: '3%', right: '4%', bottom: '12%', top: '15%', containLabel: true },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: data.map(d => d.range),
|
||
axisLabel: { interval: 0, fontSize: 13, fontWeight: 'bold' }
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
name: '銷售額',
|
||
nameTextStyle: { fontSize: 12, fontWeight: 'bold' },
|
||
axisLabel: { formatter: v => (v / 10000).toFixed(0) + '萬', fontSize: 12 }
|
||
},
|
||
series: [
|
||
{
|
||
name: '2024 銷售',
|
||
type: 'bar',
|
||
data: data.map(d => d.sales_2024 || 0),
|
||
itemStyle: { color: '#94a3b8' },
|
||
barWidth: 30,
|
||
label: {
|
||
show: true,
|
||
position: 'top',
|
||
fontSize: 12,
|
||
fontWeight: 'bold',
|
||
color: '#475569',
|
||
formatter: function (p) {
|
||
const val = p.value || 0;
|
||
const pct = totalSales2024 > 0 ? (val / totalSales2024 * 100).toFixed(1) + '%' : '';
|
||
const amt = (val / 10000).toFixed(0) + '萬';
|
||
return pct + '\n' + amt;
|
||
}
|
||
}
|
||
},
|
||
{
|
||
name: '2025 銷售',
|
||
type: 'bar',
|
||
data: data.map(d => d.sales_2025 || 0),
|
||
itemStyle: { color: '#8b5cf6' },
|
||
barWidth: 30,
|
||
label: {
|
||
show: true,
|
||
position: 'top',
|
||
fontSize: 12,
|
||
fontWeight: 'bold',
|
||
color: '#6d28d9',
|
||
formatter: function (p) {
|
||
const val = p.value || 0;
|
||
const pct = totalSales2025 > 0 ? (val / totalSales2025 * 100).toFixed(1) + '%' : '';
|
||
const amt = (val / 10000).toFixed(0) + '萬';
|
||
return pct + '\n' + amt;
|
||
}
|
||
}
|
||
}
|
||
]
|
||
};
|
||
chart.setOption(option);
|
||
|
||
// 移除表格 (清空容器)
|
||
$(`#${tableId}`).parent().html('');
|
||
}
|
||
|
||
function renderBCGMatrixChart(chart, data) {
|
||
// X: 毛利率 (Margin), Y: 銷售額 (Sales), Size: 銷量 (Vol)
|
||
if (!data || !data.length) return;
|
||
|
||
const seriesData = data.map(d => [
|
||
d.margin, // X
|
||
d.sales, // Y
|
||
d.qty, // Size
|
||
d.name // Name
|
||
]);
|
||
|
||
const option = {
|
||
tooltip: {
|
||
formatter: function (param) {
|
||
return `<div style="font-weight:bold">${param.value[3]}</div>
|
||
<div>毛利率: ${param.value[0]}%</div>
|
||
<div>銷售額: $${param.value[1].toLocaleString()}</div>
|
||
<div>銷量: ${param.value[2].toLocaleString()}</div>`;
|
||
}
|
||
},
|
||
grid: { left: '80', right: '50', top: '30', bottom: '30' },
|
||
xAxis: { name: '毛利率(%)', type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||
yAxis: { name: '業績($)', type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||
series: [
|
||
{
|
||
type: 'scatter',
|
||
data: seriesData,
|
||
symbolSize: function (data) {
|
||
// 簡單縮放:根號銷量 / 係數
|
||
return Math.sqrt(data[2]) * 1.5;
|
||
},
|
||
itemStyle: {
|
||
color: function (p) {
|
||
// 依據象限給色 (假設 30% 毛利, $50萬 業績為分界)
|
||
if (p.value[0] >= 30 && p.value[1] >= 500000) return '#10b981'; // Star
|
||
if (p.value[0] < 30 && p.value[1] >= 500000) return '#f59e0b'; // Cow
|
||
if (p.value[0] >= 30 && p.value[1] < 500000) return '#6366f1'; // Question
|
||
return '#ef4444'; // Dog
|
||
},
|
||
opacity: 0.7,
|
||
borderColor: '#fff',
|
||
borderWidth: 1
|
||
},
|
||
markLine: {
|
||
silent: true,
|
||
data: [
|
||
{ xAxis: 30, lineStyle: { color: '#999', type: 'solid' }, label: { formatter: '高獲利線 (30%)' } },
|
||
{ yAxis: 500000, lineStyle: { color: '#999', type: 'solid' }, label: { formatter: '高營收線 ($50萬)' } }
|
||
]
|
||
}
|
||
}
|
||
]
|
||
};
|
||
chart.setOption(option);
|
||
}
|
||
|
||
function renderScatterChart(chart, data) {
|
||
// Reusing BCG data for Price (calc) vs Volume
|
||
if (!data || !data.length) return;
|
||
|
||
const seriesData = data.map(d => {
|
||
const avgPrice = d.qty > 0 ? Math.round(d.sales / d.qty) : 0;
|
||
return [
|
||
avgPrice, // X: Price
|
||
d.qty, // Y: Volume
|
||
d.sales, // Size: Sales
|
||
d.name
|
||
];
|
||
});
|
||
|
||
const option = {
|
||
tooltip: {
|
||
formatter: function (param) {
|
||
return `<div style="font-weight:bold">${param.value[3]}</div>
|
||
<div>均價: $${param.value[0]}</div>
|
||
<div>銷量: ${param.value[1].toLocaleString()}</div>
|
||
<div>業績: $${param.value[2].toLocaleString()}</div>`;
|
||
}
|
||
},
|
||
grid: { left: '60', right: '50', top: '30', bottom: '30' },
|
||
xAxis: { name: '均價($)', type: 'value', splitLine: { show: false } },
|
||
yAxis: { name: '銷量', type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||
series: [
|
||
{
|
||
type: 'scatter',
|
||
data: seriesData,
|
||
symbolSize: function (data) {
|
||
return Math.log(data[2]) * 3; // Log scale size based on sales
|
||
},
|
||
itemStyle: { color: '#ec4899', opacity: 0.6 }
|
||
}
|
||
]
|
||
};
|
||
chart.setOption(option);
|
||
}
|
||
|
||
function renderVendorRankingChart(chart, tableId, data) {
|
||
if (!data || !data.length) return;
|
||
// 取前 20 名做圖表
|
||
const chartData = data.slice().reverse(); // 反轉讓第一名在上面
|
||
|
||
const option = {
|
||
title: { text: '', left: 'center' },
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||
legend: { data: ['2024 銷售額', '2025 銷售額'], bottom: 0, textStyle: { fontSize: 15, fontWeight: 'bold' } },
|
||
grid: { left: '160', right: '110', top: '20', bottom: '45' },
|
||
xAxis: {
|
||
type: 'value',
|
||
axisLabel: { formatter: v => (v / 10000).toFixed(0) + '萬', fontSize: 14, fontWeight: 'bold' }
|
||
},
|
||
yAxis: {
|
||
type: 'category',
|
||
data: chartData.map(d => d.name),
|
||
axisLabel: { width: 150, overflow: 'truncate', interval: 0, fontSize: 14, fontWeight: 'bold' }
|
||
},
|
||
series: [
|
||
{
|
||
name: '2024 銷售額', type: 'bar',
|
||
data: chartData.map(d => d.sales_2024 || 0),
|
||
itemStyle: { color: '#94a3b8' },
|
||
barWidth: 18,
|
||
barGap: '0%',
|
||
barCategoryGap: '75%',
|
||
label: {
|
||
show: true,
|
||
position: 'insideRight',
|
||
fontSize: 13,
|
||
fontWeight: 'bold',
|
||
color: '#fff',
|
||
textShadowColor: 'rgba(0,0,0,0.5)',
|
||
textShadowBlur: 2,
|
||
formatter: p => p.value > 0 ? (p.value / 10000).toFixed(0) + '萬' : ''
|
||
}
|
||
},
|
||
{
|
||
name: '2025 銷售額', type: 'bar',
|
||
data: chartData.map(d => d.sales_2025 || 0),
|
||
itemStyle: { color: '#3b82f6' },
|
||
barWidth: 18,
|
||
label: {
|
||
show: true,
|
||
position: 'right',
|
||
fontSize: 14,
|
||
fontWeight: 'bold',
|
||
color: '#1d4ed8',
|
||
formatter: p => p.value > 0 ? (p.value / 10000).toFixed(0) + '萬' : ''
|
||
}
|
||
}
|
||
]
|
||
};
|
||
chart.setOption(option);
|
||
|
||
// 表格渲染 (顯示 Top 20)
|
||
// 欄位順序:排名、廠商名稱、總銷售額、銷售額YoY、2024銷售額、2025銷售額、總毛利額、毛利額YoY、2024毛利額、2025毛利額
|
||
let tableHtml = `
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th class="align-middle">排名</th>
|
||
<th class="align-middle">廠商名稱</th>
|
||
<th class="align-middle text-end">總銷售額</th>
|
||
<th class="align-middle text-end">銷售額 YoY</th>
|
||
<th class="align-middle text-end">2024 銷售額</th>
|
||
<th class="align-middle text-end">2025 銷售額</th>
|
||
<th class="align-middle text-end">總毛利額</th>
|
||
<th class="align-middle text-end">毛利額 YoY</th>
|
||
<th class="align-middle text-end">2024 毛利額</th>
|
||
<th class="align-middle text-end">2025 毛利額</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
data.forEach((d, i) => {
|
||
const sales24 = d.sales_2024 || 0;
|
||
const sales25 = d.sales_2025 || 0;
|
||
const profit24 = d.profit_2024 || 0;
|
||
const profit25 = d.profit_2025 || 0;
|
||
|
||
const salesYoY = sales24 > 0 ? ((sales25 - sales24) / sales24 * 100).toFixed(1) + '%' : '-';
|
||
const profitYoY = profit24 > 0 ? ((profit25 - profit24) / profit24 * 100).toFixed(1) + '%' : '-';
|
||
|
||
const salesYoYClass = sales24 > 0 && (sales25 - sales24) >= 0 ? 'text-danger' : (sales24 > 0 ? 'text-success' : '');
|
||
const profitYoYClass = profit24 > 0 && (profit25 - profit24) >= 0 ? 'text-danger' : (profit24 > 0 ? 'text-success' : '');
|
||
|
||
tableHtml += `
|
||
<tr>
|
||
<td>${i + 1}</td>
|
||
<td class="text-start text-truncate" style="max-width: 200px;">${d.name}</td>
|
||
<td class="text-end fw-bold">${d.sales.toLocaleString()}</td>
|
||
<td class="text-end fw-bold ${salesYoYClass}">${salesYoY}</td>
|
||
<td class="text-end text-muted">${sales24.toLocaleString()}</td>
|
||
<td class="text-end text-primary fw-bold">${sales25.toLocaleString()}</td>
|
||
<td class="text-end fw-bold">${d.profit.toLocaleString()}</td>
|
||
<td class="text-end fw-bold ${profitYoYClass}">${profitYoY}</td>
|
||
<td class="text-end text-muted">${profit24.toLocaleString()}</td>
|
||
<td class="text-end text-success fw-bold">${profit25.toLocaleString()}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
tableHtml += `</tbody>`;
|
||
$(`#${tableId}`).parent().html(`<table class="table table-bordered table-hover table-sm text-center mb-0 small" style="white-space: nowrap;">${tableHtml}</table>`);
|
||
}
|
||
|
||
function renderSeasonalityHeatmap(chart, data) {
|
||
if (!data || !data.length) return;
|
||
|
||
// X軸: 月份 1-12
|
||
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||
|
||
// 取得所有區域名稱 (按銷售量排序)
|
||
const areaSales = {};
|
||
data.forEach(d => {
|
||
if (!areaSales[d.category]) areaSales[d.category] = 0;
|
||
areaSales[d.category] += d.sales;
|
||
});
|
||
const areas = Object.keys(areaSales).sort((a, b) => areaSales[b] - areaSales[a]);
|
||
|
||
// Y軸: 年度+區域 (2025 在上, 2024 在下)
|
||
const yAxisLabels = [];
|
||
[2025, 2024].forEach(year => {
|
||
areas.forEach(area => {
|
||
yAxisLabels.push(`${year} ${area}`);
|
||
});
|
||
});
|
||
|
||
// 構建熱力圖數據 [xIndex, yIndex, value]
|
||
const heatmapData = [];
|
||
data.forEach(d => {
|
||
const xIndex = d.month - 1;
|
||
const yLabel = `${d.year} ${d.category}`;
|
||
const yIndex = yAxisLabels.indexOf(yLabel);
|
||
if (xIndex >= 0 && xIndex < 12 && yIndex >= 0) {
|
||
heatmapData.push([xIndex, yIndex, d.sales]);
|
||
}
|
||
});
|
||
|
||
const maxSales = Math.max(...data.map(d => d.sales), 1);
|
||
|
||
const option = {
|
||
title: { text: '', left: 'center' },
|
||
tooltip: {
|
||
position: 'top',
|
||
formatter: p => {
|
||
const monthName = months[p.value[0]];
|
||
const categoryYear = yAxisLabels[p.value[1]];
|
||
const sales = p.value[2];
|
||
return `<strong>${categoryYear}</strong><br/>${monthName}<br/>業績: <strong>${(sales / 10000).toFixed(0)}萬</strong>`;
|
||
}
|
||
},
|
||
grid: { height: '75%', top: '5%', bottom: '18%', left: '15%', right: '3%' },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: months,
|
||
splitArea: { show: true },
|
||
axisLabel: { fontSize: 14, fontWeight: 'bold' }
|
||
},
|
||
yAxis: {
|
||
type: 'category',
|
||
data: yAxisLabels,
|
||
splitArea: { show: true },
|
||
axisLabel: { fontSize: 14, fontWeight: 'bold' }
|
||
},
|
||
visualMap: {
|
||
min: 0,
|
||
max: maxSales,
|
||
calculable: true,
|
||
orient: 'horizontal',
|
||
left: 'center',
|
||
bottom: '0%',
|
||
textStyle: { fontSize: 12 },
|
||
inRange: { color: ['#fef3c7', '#fbbf24', '#f59e0b', '#d97706', '#b45309'] }
|
||
},
|
||
series: [{
|
||
type: 'heatmap',
|
||
data: heatmapData,
|
||
label: {
|
||
show: true,
|
||
fontSize: 12,
|
||
fontWeight: 'bold',
|
||
formatter: function (p) {
|
||
const val = p.value[2];
|
||
return (val / 10000).toFixed(0) + '萬';
|
||
}
|
||
},
|
||
emphasis: {
|
||
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
||
}
|
||
}]
|
||
};
|
||
chart.setOption(option);
|
||
}
|
||
|
||
function renderAreaRankingChart(chart, tableId, data) {
|
||
if (!data || !data.length) return;
|
||
// 按總業績排序
|
||
data.sort((a, b) => b.sales - a.sales);
|
||
|
||
const option = {
|
||
title: { text: '', left: 'center' },
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
formatter: function (params) {
|
||
let res = params[0].name + '<br/>';
|
||
params.forEach(param => {
|
||
const val = param.value || 0;
|
||
res += `${param.marker} ${param.seriesName}: ${val.toLocaleString()}<br/>`;
|
||
});
|
||
return res;
|
||
}
|
||
},
|
||
legend: { data: ['2024 業績', '2025 業績'], bottom: 0, textStyle: { fontSize: 14 } },
|
||
grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: data.map(d => d.name),
|
||
axisLabel: { interval: 0, fontSize: 14, fontWeight: 'bold' }
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
name: '業績',
|
||
axisLabel: { formatter: v => (v / 10000).toFixed(0) + '萬', fontSize: 13 }
|
||
},
|
||
series: [
|
||
{
|
||
name: '2024 業績',
|
||
type: 'bar',
|
||
data: data.map(d => d.sales_2024 || 0),
|
||
itemStyle: { color: '#94a3b8' },
|
||
label: {
|
||
show: true,
|
||
position: 'top',
|
||
fontSize: 14,
|
||
fontWeight: 'bold',
|
||
formatter: p => (p.value / 10000).toFixed(0) + '萬'
|
||
}
|
||
},
|
||
{
|
||
name: '2025 業績',
|
||
type: 'bar',
|
||
data: data.map(d => d.sales_2025 || 0),
|
||
itemStyle: { color: '#14b8a6' }, // 青色
|
||
label: {
|
||
show: true,
|
||
position: 'top',
|
||
fontSize: 14,
|
||
fontWeight: 'bold',
|
||
formatter: p => (p.value / 10000).toFixed(0) + '萬'
|
||
}
|
||
}
|
||
]
|
||
};
|
||
chart.setOption(option);
|
||
|
||
// 表格渲染
|
||
let tableHtml = `
|
||
<thead class="table-light"><tr><th>排名</th><th>區名稱</th><th>業績</th></tr></thead><tbody>
|
||
`;
|
||
data.forEach((d, i) => {
|
||
tableHtml += `<tr><td>${i + 1}</td><td class="text-start">${d.name}</td><td class="text-end fw-bold">$${d.sales.toLocaleString()}</td></tr>`;
|
||
});
|
||
tableHtml += `</tbody>`;
|
||
$(`#${tableId}`).parent().html(`<table class="table table-bordered table-hover table-sm text-center mb-0 small">${tableHtml}</table>`);
|
||
}
|
||
</script>
|
||
{% endblock %}
|