feat: WIP Overview Hold Summary 改為柏拉圖視覺化
將 Hold Summary 從單一表格改為兩張柏拉圖呈現品質異常與非品質異常 Hold 分佈, 便於快速辨識主要原因並支援 80/20 法則分析。 - 新增 ECharts 柏拉圖:Y軸(左)為 QTY 柱狀圖、Y軸(右)為累計百分比折線 - 每張圖下方保留摘要表格顯示 Hold Reason、Lots、QTY、累計% - 保留點擊 drill-down 至 Hold Detail 頁面功能 - 響應式設計:大螢幕並排、小螢幕堆疊 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-02
|
||||
@@ -0,0 +1,73 @@
|
||||
## Context
|
||||
|
||||
WIP Overview 頁面目前以表格呈現 Hold Summary,資料來自 `/api/wip/overview/hold` API,回傳包含 `holdType: 'quality' | 'non-quality'` 分類。現有實作使用 `renderHold()` 函數動態產生表格 HTML。
|
||||
|
||||
專案已整合 ECharts 圖表庫(`/static/js/echarts.min.js`),在 `resource_history.html` 有完整使用範例。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 以柏拉圖呈現 Hold 分佈,快速辨識主要原因
|
||||
- 分離品質/非品質異常為兩張獨立圖表
|
||||
- 保留 drill-down 至 Hold Detail 頁面功能
|
||||
- 在圖表下方提供摘要表格顯示精確數據
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修改後端 API(資料結構已足夠)
|
||||
- 不新增其他圖表類型
|
||||
- 不改變 Hold Detail 頁面
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 圖表庫選擇:使用 ECharts
|
||||
|
||||
**決定**:沿用專案已整合的 ECharts
|
||||
|
||||
**理由**:
|
||||
- 已在 `resource_history.html` 驗證可行
|
||||
- 無需額外依賴
|
||||
- 支援 bar + line 組合圖(柏拉圖標準呈現)
|
||||
|
||||
### 2. 資料分組策略:前端過濾
|
||||
|
||||
**決定**:在前端使用 `filter()` 按 `holdType` 分組
|
||||
|
||||
**理由**:
|
||||
- API 已回傳 `holdType` 欄位
|
||||
- 避免增加後端 API 複雜度
|
||||
- 資料量小(通常 < 50 筆),前端處理無效能疑慮
|
||||
|
||||
### 3. 累計百分比計算:以 QTY 為基準
|
||||
|
||||
**決定**:柏拉圖 Y 軸顯示 QTY,累計線以 QTY 累計百分比計算
|
||||
|
||||
**理由**:
|
||||
- QTY 更能反映實際影響規模
|
||||
- 柱狀圖與累計線使用相同基準(QTY),圖表邏輯一致
|
||||
- Lots 數量僅在下方摘要表格作為參考資訊呈現
|
||||
|
||||
### 4. Drill-down 實作:ECharts click 事件
|
||||
|
||||
**決定**:使用 ECharts `on('click')` 事件導向 Hold Detail 頁面
|
||||
|
||||
**理由**:
|
||||
- 與現有表格連結行為一致
|
||||
- 使用者可點擊柱狀圖或摘要表格連結
|
||||
|
||||
### 5. 版面配置:兩欄並排
|
||||
|
||||
**決定**:品質異常與非品質異常柏拉圖並排顯示
|
||||
|
||||
**理由**:
|
||||
- 便於同時比較兩類 Hold 分佈
|
||||
- 螢幕空間利用率高
|
||||
- 響應式設計在小螢幕改為垂直堆疊
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 風險 | 緩解措施 |
|
||||
|------|----------|
|
||||
| X 軸標籤過長被截斷 | 設定 `axisLabel.rotate: 45` 並限制最大字元數 |
|
||||
| 某分類無資料時顯示空白 | 顯示「目前無資料」提示訊息 |
|
||||
| 響應式設計複雜度 | 使用 CSS Grid 配合 media query |
|
||||
| 累計線在少量資料時跳躍明顯 | 可接受,這是柏拉圖正常特性 |
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
WIP Overview 頁面的 Hold Summary 目前以表格呈現,難以快速辨識主要的 Hold 原因分佈。使用柏拉圖(Pareto)圖可以更直觀地顯示:哪些 Hold 原因佔據最多比例,並透過累計百分比線幫助識別 80/20 法則中的關鍵因素。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 將 Hold Summary 從單一表格改為**兩張柏拉圖**:
|
||||
- 品質異常 Hold (Quality)
|
||||
- 非品質異常 Hold (Non-Quality)
|
||||
- 每張柏拉圖包含:
|
||||
- Y 軸(左):QTY 數量(柱狀圖)
|
||||
- Y 軸(右):累計百分比(折線圖)
|
||||
- X 軸:Hold Reason 名稱
|
||||
- 每張圖下方保留**摘要表格**顯示:Hold Reason、Lots、QTY、累計%(以 QTY 為基準)
|
||||
- 保留 **Drill-down 功能**:點擊柏拉圖柱狀或表格連結可跳轉至 Hold Detail 頁面
|
||||
- **移除**原本的單一表格呈現方式
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `hold-pareto-chart`: 在 WIP Overview 頁面以柏拉圖呈現 Hold 資料分佈,支援品質/非品質分類與互動式 drill-down
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
_(無需修改現有 spec - API 已提供完整資料,僅前端視覺化變更)_
|
||||
|
||||
## Impact
|
||||
|
||||
- **前端**:`src/mes_dashboard/templates/wip_overview.html`
|
||||
- 加入 ECharts 圖表庫引用
|
||||
- 新增兩組 chart container 與摘要表格
|
||||
- 新增 `renderQualityPareto()`, `renderNonQualityPareto()` 函數
|
||||
- 移除原本的 `renderHold()` 表格邏輯
|
||||
- **後端**:無變更(API `/api/wip/overview/hold` 已提供 `holdType` 分類)
|
||||
- **依賴**:ECharts 已整合於專案(`/static/js/echarts.min.js`)
|
||||
@@ -0,0 +1,65 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hold Summary 柏拉圖視覺化
|
||||
|
||||
系統 SHALL 在 WIP Overview 頁面以兩張柏拉圖呈現 Hold 資料分佈,分別為品質異常 Hold 與非品質異常 Hold。
|
||||
|
||||
#### Scenario: 顯示品質異常柏拉圖
|
||||
- **WHEN** 頁面載入 Hold 資料且存在品質異常 Hold
|
||||
- **THEN** 系統顯示品質異常柏拉圖,X 軸為 Hold Reason,Y 軸(左)為 QTY 柱狀圖,Y 軸(右)為累計百分比折線圖
|
||||
|
||||
#### Scenario: 顯示非品質異常柏拉圖
|
||||
- **WHEN** 頁面載入 Hold 資料且存在非品質異常 Hold
|
||||
- **THEN** 系統顯示非品質異常柏拉圖,X 軸為 Hold Reason,Y 軸(左)為 QTY 柱狀圖,Y 軸(右)為累計百分比折線圖
|
||||
|
||||
#### Scenario: 無資料時顯示提示
|
||||
- **WHEN** 某分類(品質或非品質)無 Hold 資料
|
||||
- **THEN** 該分類圖表區域顯示「目前無資料」提示訊息
|
||||
|
||||
### Requirement: 柏拉圖排序與累計計算
|
||||
|
||||
系統 SHALL 按 QTY 降序排列 Hold Reason,並以 QTY 計算累計百分比。
|
||||
|
||||
#### Scenario: QTY 降序排列
|
||||
- **WHEN** 渲染柏拉圖
|
||||
- **THEN** Hold Reason 按 QTY 由高至低排列於 X 軸
|
||||
|
||||
#### Scenario: 累計百分比計算
|
||||
- **WHEN** 渲染柏拉圖累計線
|
||||
- **THEN** 累計百分比以各 Reason 的 QTY 佔該分類總 QTY 比例累加計算
|
||||
|
||||
### Requirement: 柏拉圖摘要表格
|
||||
|
||||
系統 SHALL 在每張柏拉圖下方顯示摘要表格,包含 Hold Reason、Lots、QTY、累計%。
|
||||
|
||||
#### Scenario: 摘要表格欄位
|
||||
- **WHEN** 渲染摘要表格
|
||||
- **THEN** 表格包含欄位:Hold Reason(可點擊)、Lots 數量、QTY 數量、累計百分比
|
||||
|
||||
#### Scenario: 摘要表格排序
|
||||
- **WHEN** 渲染摘要表格
|
||||
- **THEN** 表格資料順序與柏拉圖一致(QTY 降序)
|
||||
|
||||
### Requirement: Hold Reason Drill-down
|
||||
|
||||
系統 SHALL 支援點擊柏拉圖柱狀或摘要表格連結跳轉至 Hold Detail 頁面。
|
||||
|
||||
#### Scenario: 點擊柏拉圖柱狀
|
||||
- **WHEN** 使用者點擊柏拉圖中的某一柱狀
|
||||
- **THEN** 導向 `/hold-detail?reason={encoded_reason}` 頁面
|
||||
|
||||
#### Scenario: 點擊摘要表格連結
|
||||
- **WHEN** 使用者點擊摘要表格中的 Hold Reason 連結
|
||||
- **THEN** 導向 `/hold-detail?reason={encoded_reason}` 頁面
|
||||
|
||||
### Requirement: 響應式版面配置
|
||||
|
||||
系統 SHALL 支援響應式設計,大螢幕兩欄並排,小螢幕垂直堆疊。
|
||||
|
||||
#### Scenario: 大螢幕並排顯示
|
||||
- **WHEN** 螢幕寬度 >= 1200px
|
||||
- **THEN** 品質異常與非品質異常柏拉圖並排顯示
|
||||
|
||||
#### Scenario: 小螢幕堆疊顯示
|
||||
- **WHEN** 螢幕寬度 < 1200px
|
||||
- **THEN** 品質異常與非品質異常柏拉圖垂直堆疊顯示
|
||||
@@ -0,0 +1,33 @@
|
||||
## 1. 準備工作
|
||||
|
||||
- [x] 1.1 在 wip_overview.html 加入 ECharts 腳本引用
|
||||
- [x] 1.2 新增柏拉圖容器 HTML 結構(品質/非品質兩組 chart + 表格)
|
||||
|
||||
## 2. 資料處理函數
|
||||
|
||||
- [x] 2.1 實作 `splitHoldByType()` 函數,將 Hold 資料按 holdType 分為品質/非品質兩組
|
||||
- [x] 2.2 實作 `prepareParetoData()` 函數,按 QTY 降序排列並計算累計百分比
|
||||
|
||||
## 3. 柏拉圖渲染
|
||||
|
||||
- [x] 3.1 實作 `initParetoCharts()` 函數,初始化兩個 ECharts 實例
|
||||
- [x] 3.2 實作 `renderParetoChart()` 函數,配置柏拉圖選項(bar + line 組合圖)
|
||||
- [x] 3.3 加入 ECharts click 事件處理,實作 drill-down 導向 Hold Detail 頁面
|
||||
- [x] 3.4 處理無資料情況,顯示「目前無資料」提示
|
||||
|
||||
## 4. 摘要表格
|
||||
|
||||
- [x] 4.1 實作 `renderParetoTable()` 函數,渲染摘要表格(Reason、Lots、QTY、累計%)
|
||||
- [x] 4.2 表格 Hold Reason 欄位加入 drill-down 連結
|
||||
|
||||
## 5. 樣式與響應式
|
||||
|
||||
- [x] 5.1 新增柏拉圖區塊 CSS 樣式
|
||||
- [x] 5.2 實作響應式設計:大螢幕並排、小螢幕堆疊
|
||||
- [x] 5.3 加入 window resize 事件處理,重新調整圖表尺寸
|
||||
|
||||
## 6. 整合與清理
|
||||
|
||||
- [x] 6.1 修改 `loadHold()` 呼叫新的渲染函數
|
||||
- [x] 6.2 移除原本的 `renderHold()` 表格函數及相關 HTML/CSS
|
||||
- [x] 6.3 測試驗證:資料載入、圖表渲染、drill-down 功能、響應式切換
|
||||
63
openspec/specs/hold-pareto-chart/spec.md
Normal file
63
openspec/specs/hold-pareto-chart/spec.md
Normal file
@@ -0,0 +1,63 @@
|
||||
### Requirement: Hold Summary 柏拉圖視覺化
|
||||
|
||||
系統 SHALL 在 WIP Overview 頁面以兩張柏拉圖呈現 Hold 資料分佈,分別為品質異常 Hold 與非品質異常 Hold。
|
||||
|
||||
#### Scenario: 顯示品質異常柏拉圖
|
||||
- **WHEN** 頁面載入 Hold 資料且存在品質異常 Hold
|
||||
- **THEN** 系統顯示品質異常柏拉圖,X 軸為 Hold Reason,Y 軸(左)為 QTY 柱狀圖,Y 軸(右)為累計百分比折線圖
|
||||
|
||||
#### Scenario: 顯示非品質異常柏拉圖
|
||||
- **WHEN** 頁面載入 Hold 資料且存在非品質異常 Hold
|
||||
- **THEN** 系統顯示非品質異常柏拉圖,X 軸為 Hold Reason,Y 軸(左)為 QTY 柱狀圖,Y 軸(右)為累計百分比折線圖
|
||||
|
||||
#### Scenario: 無資料時顯示提示
|
||||
- **WHEN** 某分類(品質或非品質)無 Hold 資料
|
||||
- **THEN** 該分類圖表區域顯示「目前無資料」提示訊息
|
||||
|
||||
### Requirement: 柏拉圖排序與累計計算
|
||||
|
||||
系統 SHALL 按 QTY 降序排列 Hold Reason,並以 QTY 計算累計百分比。
|
||||
|
||||
#### Scenario: QTY 降序排列
|
||||
- **WHEN** 渲染柏拉圖
|
||||
- **THEN** Hold Reason 按 QTY 由高至低排列於 X 軸
|
||||
|
||||
#### Scenario: 累計百分比計算
|
||||
- **WHEN** 渲染柏拉圖累計線
|
||||
- **THEN** 累計百分比以各 Reason 的 QTY 佔該分類總 QTY 比例累加計算
|
||||
|
||||
### Requirement: 柏拉圖摘要表格
|
||||
|
||||
系統 SHALL 在每張柏拉圖下方顯示摘要表格,包含 Hold Reason、Lots、QTY、累計%。
|
||||
|
||||
#### Scenario: 摘要表格欄位
|
||||
- **WHEN** 渲染摘要表格
|
||||
- **THEN** 表格包含欄位:Hold Reason(可點擊)、Lots 數量、QTY 數量、累計百分比
|
||||
|
||||
#### Scenario: 摘要表格排序
|
||||
- **WHEN** 渲染摘要表格
|
||||
- **THEN** 表格資料順序與柏拉圖一致(QTY 降序)
|
||||
|
||||
### Requirement: Hold Reason Drill-down
|
||||
|
||||
系統 SHALL 支援點擊柏拉圖柱狀或摘要表格連結跳轉至 Hold Detail 頁面。
|
||||
|
||||
#### Scenario: 點擊柏拉圖柱狀
|
||||
- **WHEN** 使用者點擊柏拉圖中的某一柱狀
|
||||
- **THEN** 導向 `/hold-detail?reason={encoded_reason}` 頁面
|
||||
|
||||
#### Scenario: 點擊摘要表格連結
|
||||
- **WHEN** 使用者點擊摘要表格中的 Hold Reason 連結
|
||||
- **THEN** 導向 `/hold-detail?reason={encoded_reason}` 頁面
|
||||
|
||||
### Requirement: 響應式版面配置
|
||||
|
||||
系統 SHALL 支援響應式設計,大螢幕兩欄並排,小螢幕垂直堆疊。
|
||||
|
||||
#### Scenario: 大螢幕並排顯示
|
||||
- **WHEN** 螢幕寬度 >= 1200px
|
||||
- **THEN** 品質異常與非品質異常柏拉圖並排顯示
|
||||
|
||||
#### Scenario: 小螢幕堆疊顯示
|
||||
- **WHEN** 螢幕寬度 < 1200px
|
||||
- **THEN** 品質異常與非品質異常柏拉圖垂直堆疊顯示
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}WIP Overview Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="/static/js/echarts.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
@@ -436,8 +437,8 @@
|
||||
|
||||
/* Content Grid */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -612,6 +613,139 @@
|
||||
color: #9A3412;
|
||||
}
|
||||
|
||||
/* Pareto Chart Styles */
|
||||
.pareto-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pareto-section {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pareto-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.pareto-header.quality {
|
||||
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||
border-bottom-color: #fca5a5;
|
||||
}
|
||||
|
||||
.pareto-header.non-quality {
|
||||
background: linear-gradient(135deg, #ffedd5 0%, #fed7aa 100%);
|
||||
border-bottom-color: #fdba74;
|
||||
}
|
||||
|
||||
.pareto-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pareto-header.quality .pareto-title {
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
.pareto-header.non-quality .pareto-title {
|
||||
color: #9A3412;
|
||||
}
|
||||
|
||||
.pareto-title .badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.pareto-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pareto-chart {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.pareto-no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 280px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pareto-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pareto-table th,
|
||||
.pareto-table td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.pareto-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pareto-table td:nth-child(2),
|
||||
.pareto-table td:nth-child(3),
|
||||
.pareto-table td:nth-child(4) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pareto-table th:nth-child(2),
|
||||
.pareto-table th:nth-child(3),
|
||||
.pareto-table th:nth-child(4) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pareto-table tbody tr:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.pareto-table .reason-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pareto-table .reason-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.pareto-table .cumulative {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Responsive: stack on smaller screens */
|
||||
@media (max-width: 1200px) {
|
||||
.pareto-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
@@ -784,14 +918,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hold Summary -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Hold Summary</div>
|
||||
<!-- Hold Summary - Pareto Charts -->
|
||||
<div class="pareto-grid" id="holdParetoContainer">
|
||||
<!-- Quality Hold Pareto -->
|
||||
<div class="pareto-section">
|
||||
<div class="pareto-header quality">
|
||||
<div class="pareto-title">
|
||||
品質異常 Hold
|
||||
<span class="badge" id="qualityHoldCount">0 項</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pareto-body">
|
||||
<div id="qualityParetoChart" class="pareto-chart"></div>
|
||||
<div id="qualityParetoNoData" class="pareto-no-data" style="display: none;">目前無資料</div>
|
||||
<div id="qualityParetoTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 500px; overflow: auto;">
|
||||
<div id="holdContainer">
|
||||
<div class="placeholder">Loading...</div>
|
||||
<!-- Non-Quality Hold Pareto -->
|
||||
<div class="pareto-section">
|
||||
<div class="pareto-header non-quality">
|
||||
<div class="pareto-title">
|
||||
非品質異常 Hold
|
||||
<span class="badge" id="nonQualityHoldCount">0 項</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pareto-body">
|
||||
<div id="nonQualityParetoChart" class="pareto-chart"></div>
|
||||
<div id="nonQualityParetoNoData" class="pareto-no-data" style="display: none;">目前無資料</div>
|
||||
<div id="nonQualityParetoTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1305,31 +1459,184 @@
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderHold(data) {
|
||||
const container = document.getElementById('holdContainer');
|
||||
// ============================================================
|
||||
// Pareto Chart Functions
|
||||
// ============================================================
|
||||
let paretoCharts = {
|
||||
quality: null,
|
||||
nonQuality: null
|
||||
};
|
||||
|
||||
if (!data || !data.items || data.items.length === 0) {
|
||||
container.innerHTML = '<div class="placeholder">No hold lots</div>';
|
||||
// Task 2.1: Split hold data by type
|
||||
function splitHoldByType(data) {
|
||||
if (!data || !data.items) {
|
||||
return { quality: [], nonQuality: [] };
|
||||
}
|
||||
const quality = data.items.filter(item => item.holdType === 'quality');
|
||||
const nonQuality = data.items.filter(item => item.holdType !== 'quality');
|
||||
return { quality, nonQuality };
|
||||
}
|
||||
|
||||
// Task 2.2: Prepare Pareto data (sort by QTY desc, calculate cumulative %)
|
||||
function prepareParetoData(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return { reasons: [], qtys: [], lots: [], cumulative: [], totalQty: 0 };
|
||||
}
|
||||
|
||||
// Sort by QTY descending
|
||||
const sorted = [...items].sort((a, b) => (b.qty || 0) - (a.qty || 0));
|
||||
|
||||
const reasons = sorted.map(item => item.reason || '未知');
|
||||
const qtys = sorted.map(item => item.qty || 0);
|
||||
const lots = sorted.map(item => item.lots || 0);
|
||||
const totalQty = qtys.reduce((sum, q) => sum + q, 0);
|
||||
|
||||
// Calculate cumulative percentage
|
||||
const cumulative = [];
|
||||
let runningSum = 0;
|
||||
qtys.forEach(qty => {
|
||||
runningSum += qty;
|
||||
cumulative.push(totalQty > 0 ? Math.round((runningSum / totalQty) * 100) : 0);
|
||||
});
|
||||
|
||||
return { reasons, qtys, lots, cumulative, totalQty, items: sorted };
|
||||
}
|
||||
|
||||
// Task 3.1: Initialize Pareto charts
|
||||
function initParetoCharts() {
|
||||
const qualityEl = document.getElementById('qualityParetoChart');
|
||||
const nonQualityEl = document.getElementById('nonQualityParetoChart');
|
||||
|
||||
if (qualityEl && !paretoCharts.quality) {
|
||||
paretoCharts.quality = echarts.init(qualityEl);
|
||||
}
|
||||
if (nonQualityEl && !paretoCharts.nonQuality) {
|
||||
paretoCharts.nonQuality = echarts.init(nonQualityEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Task 3.2: Render Pareto chart with ECharts
|
||||
function renderParetoChart(chart, paretoData, colorTheme) {
|
||||
if (!chart) return;
|
||||
|
||||
const barColor = colorTheme === 'quality' ? '#ef4444' : '#f97316';
|
||||
const lineColor = colorTheme === 'quality' ? '#991B1B' : '#9A3412';
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter: function(params) {
|
||||
const reason = params[0].name;
|
||||
const qty = params[0].value;
|
||||
const cumPct = params[1] ? params[1].value : 0;
|
||||
return `<strong>${reason}</strong><br/>QTY: ${formatNumber(qty)}<br/>累計: ${cumPct}%`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: '10%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: paretoData.reasons,
|
||||
axisLabel: {
|
||||
rotate: 30,
|
||||
interval: 0,
|
||||
fontSize: 10,
|
||||
formatter: function(value) {
|
||||
return value.length > 12 ? value.slice(0, 12) + '...' : value;
|
||||
}
|
||||
},
|
||||
axisTick: { alignWithLabel: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'QTY',
|
||||
position: 'left',
|
||||
axisLabel: {
|
||||
formatter: function(val) {
|
||||
return val >= 1000 ? (val / 1000).toFixed(0) + 'k' : val;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '累計%',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'QTY',
|
||||
type: 'bar',
|
||||
data: paretoData.qtys,
|
||||
itemStyle: { color: barColor },
|
||||
emphasis: {
|
||||
itemStyle: { color: barColor, opacity: 0.8 }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '累計%',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: paretoData.cumulative,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: lineColor, width: 2 },
|
||||
itemStyle: { color: lineColor }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
|
||||
// Task 3.3: Add click event for drill-down
|
||||
chart.off('click'); // Remove existing handlers
|
||||
chart.on('click', function(params) {
|
||||
if (params.componentType === 'series' && params.seriesType === 'bar') {
|
||||
const reason = paretoData.reasons[params.dataIndex];
|
||||
if (reason && reason !== '未知') {
|
||||
window.location.href = `/hold-detail?reason=${encodeURIComponent(reason)}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Task 4.1 & 4.2: Render Pareto table with drill-down links
|
||||
function renderParetoTable(containerId, paretoData) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (!paretoData.items || paretoData.items.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="hold-table"><thead><tr>';
|
||||
let html = '<table class="pareto-table"><thead><tr>';
|
||||
html += '<th>Hold Reason</th>';
|
||||
html += '<th>Lots</th>';
|
||||
html += '<th>QTY</th>';
|
||||
html += '<th>累計%</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
data.items.forEach(item => {
|
||||
const badgeClass = item.holdType === 'quality' ? 'quality' : 'non-quality';
|
||||
const badgeText = item.holdType === 'quality' ? '品質' : '非品質';
|
||||
const reasonText = item.reason || '-';
|
||||
paretoData.items.forEach((item, idx) => {
|
||||
const reason = item.reason || '未知';
|
||||
const reasonLink = item.reason
|
||||
? `<a href="/hold-detail?reason=${encodeURIComponent(item.reason)}" class="hold-reason-link">${reasonText}</a>`
|
||||
: reasonText;
|
||||
? `<a href="/hold-detail?reason=${encodeURIComponent(item.reason)}" class="reason-link">${reason}</a>`
|
||||
: reason;
|
||||
html += '<tr>';
|
||||
html += `<td><span class="hold-type-badge ${badgeClass}">${badgeText}</span>${reasonLink}</td>`;
|
||||
html += `<td>${reasonLink}</td>`;
|
||||
html += `<td>${formatNumber(item.lots)}</td>`;
|
||||
html += `<td>${formatNumber(item.qty)}</td>`;
|
||||
html += `<td class="cumulative">${paretoData.cumulative[idx]}%</td>`;
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
@@ -1337,6 +1644,55 @@
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Task 3.4: Handle no data state
|
||||
function showParetoNoData(type, show) {
|
||||
const chartEl = document.getElementById(`${type}ParetoChart`);
|
||||
const noDataEl = document.getElementById(`${type}ParetoNoData`);
|
||||
if (chartEl) chartEl.style.display = show ? 'none' : 'block';
|
||||
if (noDataEl) noDataEl.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// Main render function for Hold data
|
||||
function renderHold(data) {
|
||||
initParetoCharts();
|
||||
|
||||
const { quality, nonQuality } = splitHoldByType(data);
|
||||
const qualityData = prepareParetoData(quality);
|
||||
const nonQualityData = prepareParetoData(nonQuality);
|
||||
|
||||
// Update counts in header
|
||||
document.getElementById('qualityHoldCount').textContent = `${quality.length} 項`;
|
||||
document.getElementById('nonQualityHoldCount').textContent = `${nonQuality.length} 項`;
|
||||
|
||||
// Quality Pareto
|
||||
if (quality.length > 0) {
|
||||
showParetoNoData('quality', false);
|
||||
renderParetoChart(paretoCharts.quality, qualityData, 'quality');
|
||||
renderParetoTable('qualityParetoTable', qualityData);
|
||||
} else {
|
||||
showParetoNoData('quality', true);
|
||||
if (paretoCharts.quality) paretoCharts.quality.clear();
|
||||
document.getElementById('qualityParetoTable').innerHTML = '';
|
||||
}
|
||||
|
||||
// Non-Quality Pareto
|
||||
if (nonQuality.length > 0) {
|
||||
showParetoNoData('nonQuality', false);
|
||||
renderParetoChart(paretoCharts.nonQuality, nonQualityData, 'non-quality');
|
||||
renderParetoTable('nonQualityParetoTable', nonQualityData);
|
||||
} else {
|
||||
showParetoNoData('nonQuality', true);
|
||||
if (paretoCharts.nonQuality) paretoCharts.nonQuality.clear();
|
||||
document.getElementById('nonQualityParetoTable').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Task 5.3: Window resize handler for charts
|
||||
window.addEventListener('resize', function() {
|
||||
if (paretoCharts.quality) paretoCharts.quality.resize();
|
||||
if (paretoCharts.nonQuality) paretoCharts.nonQuality.resize();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Navigation
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user