feat(resource): migrate resource-status and resource-history from Jinja2 to Vue 3 + Vite
Rewrite both resource pages (1,697 lines vanilla JS + 3,200 lines Jinja2 templates) as Vue 3 SFC components. Extract resource-shared/ module with shared CSS, E10 status constants, and HierarchyTable tree component. History page charts use vue-echarts, Status page reuses useAutoRefresh composable with 5-minute interval. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
62
README.md
62
README.md
@@ -38,12 +38,14 @@
|
|||||||
| QC-GATE 即時狀態報表(Vue 3 + Vite) | ✅ 已完成 |
|
| QC-GATE 即時狀態報表(Vue 3 + Vite) | ✅ 已完成 |
|
||||||
| 數據表查詢頁面 Vue 3 遷移 | ✅ 已完成 |
|
| 數據表查詢頁面 Vue 3 遷移 | ✅ 已完成 |
|
||||||
| WIP 三頁 Vue 3 遷移(Overview/Detail/Hold Detail) | ✅ 已完成 |
|
| WIP 三頁 Vue 3 遷移(Overview/Detail/Hold Detail) | ✅ 已完成 |
|
||||||
|
| 設備雙頁 Vue 3 遷移(Status/History) | ✅ 已完成 |
|
||||||
| 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 |
|
| 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 開發歷史(Vite 重構後)
|
## 開發歷史(Vite 重構後)
|
||||||
|
|
||||||
|
- 2026-02-09:完成設備雙頁 Vue 3 遷移(`/resource`、`/resource-history`)— 兩頁共 1,697 行 vanilla JS + 3,200 行 Jinja2 模板重寫為 Vue 3 SFC。抽取 `resource-shared/` 共用模組(CSS 基底、E10 狀態常數、HierarchyTable 三層展開樹表元件),History 頁 4 個 ECharts 圖表改用 vue-echarts,Status 頁複用 `useAutoRefresh` composable(5 分鐘自動刷新)。
|
||||||
- 2026-02-09:完成 WIP 三頁 Vue 3 遷移(`/wip-overview`、`/wip-detail`、`/hold-detail`)— 三頁共 1,941 行 vanilla JS + Jinja2 重寫為 Vue 3 SFC。抽取共用 CSS/常數/元件至 `wip-shared/`,Pareto 圖改用 vue-echarts(與 QC-GATE 一致),Hold Detail 新增前端 URL params 判斷取代 Jinja2 注入。
|
- 2026-02-09:完成 WIP 三頁 Vue 3 遷移(`/wip-overview`、`/wip-detail`、`/hold-detail`)— 三頁共 1,941 行 vanilla JS + Jinja2 重寫為 Vue 3 SFC。抽取共用 CSS/常數/元件至 `wip-shared/`,Pareto 圖改用 vue-echarts(與 QC-GATE 一致),Hold Detail 新增前端 URL params 判斷取代 Jinja2 注入。
|
||||||
- 2026-02-09:完成數據表查詢頁面(`/tables`)Vue 3 遷移 — 第二個純 Vite 頁面,建立 `apiPost` POST 請求模式,237 行 vanilla JS 重寫為 Vue 3 SFC 元件。
|
- 2026-02-09:完成數據表查詢頁面(`/tables`)Vue 3 遷移 — 第二個純 Vite 頁面,建立 `apiPost` POST 請求模式,237 行 vanilla JS 重寫為 Vue 3 SFC 元件。
|
||||||
- 2026-02-09:修復設備快取 DataFrame TTL 一致性問題 — process-level DataFrame(30s TTL)過期後 derived index 仍為 ready,導致 `/api/resource/status` 回傳空資料。新增 Redis fallback reload。
|
- 2026-02-09:修復設備快取 DataFrame TTL 一致性問題 — process-level DataFrame(30s TTL)過期後 derived index 仍為 ready,導致 `/api/resource/status` 回傳空資料。新增 Redis fallback reload。
|
||||||
@@ -423,13 +425,14 @@ sudo systemctl start mes-dashboard mes-dashboard-watchdog
|
|||||||
- **生產設備**:僅顯示列入生產統計的設備
|
- **生產設備**:僅顯示列入生產統計的設備
|
||||||
- **重點設備**:僅顯示標記為重點監控的設備
|
- **重點設備**:僅顯示標記為重點監控的設備
|
||||||
- **監控設備**:僅顯示需特別監控的設備
|
- **監控設備**:僅顯示需特別監控的設備
|
||||||
- 設備狀態每 30 秒自動更新
|
- 5 分鐘自動刷新 + 手動刷新按鈕
|
||||||
|
|
||||||
#### 設備歷史查詢
|
#### 設備歷史績效
|
||||||
1. 選擇查詢日期範圍
|
1. 選擇查詢日期範圍與粒度(日/週/月/年)
|
||||||
2. 可選擇特定設備或工作中心
|
2. 可多選 workcenter groups 和 families 篩選
|
||||||
3. 查看歷史趨勢圖表和稼動率熱力圖
|
3. 查看趨勢折線、堆疊柱狀、workcenter 對比與 OU% 熱圖
|
||||||
4. 支援 CSV 匯出
|
4. 三層階層明細表顯示各設備 hours 與百分比
|
||||||
|
5. 支援 CSV 匯出
|
||||||
|
|
||||||
### 管理員登入
|
### 管理員登入
|
||||||
|
|
||||||
@@ -491,20 +494,25 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料
|
|||||||
- 10 分鐘自動刷新 + AbortController 請求取消
|
- 10 分鐘自動刷新 + AbortController 請求取消
|
||||||
- **技術架構**:Vue 3 + Vite,URL params 取代 Jinja2 注入
|
- **技術架構**:Vue 3 + Vite,URL params 取代 Jinja2 注入
|
||||||
|
|
||||||
### 設備狀態監控
|
### 設備即時概況
|
||||||
|
|
||||||
- 即時設備狀態總覽(PRD/SBY/UDT/SDT/EGT/NST)
|
- 即時設備狀態總覽:10 張 KPI 卡片(OU%/AVAIL%/PRD/SBY/UDT/SDT/EGT/NST/OTHER/Total)
|
||||||
- 按工作中心群組統計
|
- 三層階層矩陣表(workcenter group → family → resource),支援展開/收合與 cell click 篩選
|
||||||
- 設備稼動率(OU%)與運轉率(RUN%)
|
- 設備卡片格(auto-fill grid),顯示狀態/位置/LOT/JOB 資訊
|
||||||
- 階層篩選(廠區/產線/重點設備/監控設備)
|
- LOT/JOB 浮動 tooltip(`<Teleport>` + viewport clamp 定位)
|
||||||
- Redis 快取自動更新(30 秒間隔)
|
- 階層篩選(workcenter group 下拉 + 生產設備/重點設備/監控設備 checkbox)
|
||||||
|
- 5 分鐘自動刷新 + `visibilitychange` 即時刷新 + AbortController
|
||||||
|
- Cache 狀態指示(green/yellow/red dot + 最後更新時間)
|
||||||
|
- **技術架構**:Vue 3 + Vite,複用 `resource-shared/` 共用模組與 `useAutoRefresh` composable
|
||||||
|
|
||||||
### 設備歷史查詢
|
### 設備歷史績效
|
||||||
|
|
||||||
- 歷史狀態趨勢分析
|
- 9 張 KPI 卡片(OU%/AVAIL%/PRD/SBY/UDT/SDT/EGT/NST/機台數)
|
||||||
- 稼動率熱力圖視覺化
|
- 4 個 vue-echarts 圖表:OU%/AVAIL% 趨勢折線、E10 狀態堆疊柱狀、workcenter OU% 橫向對比、OU% 熱圖
|
||||||
- 設備狀態明細查詢
|
- 三層階層明細表(使用 `resource-shared/HierarchyTable`),hours + percentage 格式
|
||||||
- 支援 CSV 匯出
|
- 日期區間(預設 7 天)+ 粒度切換(日/週/月/年)+ 多選下拉篩選
|
||||||
|
- CSV 串流匯出(`/api/resource/history/export`)
|
||||||
|
- **技術架構**:Vue 3 + Vite,4 圖表全部使用 `<VChart :option autoresize />`
|
||||||
|
|
||||||
### QC-GATE 即時狀態報表
|
### QC-GATE 即時狀態報表
|
||||||
|
|
||||||
@@ -580,8 +588,8 @@ CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30
|
|||||||
| 技術 | 用途 |
|
| 技術 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Jinja2 | 模板引擎(既有頁面) |
|
| Jinja2 | 模板引擎(既有頁面) |
|
||||||
| Vue 3 | UI 框架(QC-GATE、Tables、WIP 三頁已遷移,漸進式擴展中) |
|
| Vue 3 | UI 框架(QC-GATE、Tables、WIP 三頁、設備雙頁已遷移,漸進式擴展中) |
|
||||||
| vue-echarts | ECharts Vue 封裝(QC-GATE、WIP Overview Pareto 圖) |
|
| vue-echarts | ECharts Vue 封裝(QC-GATE、WIP Overview Pareto、Resource History 4 圖表) |
|
||||||
| Vite 6 | 前端多頁模組打包(含 Vue SFC + HTML entry) |
|
| Vite 6 | 前端多頁模組打包(含 Vue SFC + HTML entry) |
|
||||||
| ECharts | 圖表庫(npm tree-shaking + 舊版靜態檔案並存) |
|
| ECharts | 圖表庫(npm tree-shaking + 舊版靜態檔案並存) |
|
||||||
| Vanilla JS Modules | 互動功能與頁面邏輯(既有頁面) |
|
| Vanilla JS Modules | 互動功能與頁面邏輯(既有頁面) |
|
||||||
@@ -637,8 +645,9 @@ DashBoard_vite/
|
|||||||
├── frontend/ # Vite 前端專案
|
├── frontend/ # Vite 前端專案
|
||||||
│ ├── src/core/ # 共用 API/欄位契約/計算 helper
|
│ ├── src/core/ # 共用 API/欄位契約/計算 helper
|
||||||
│ ├── src/portal/ # Portal entry
|
│ ├── src/portal/ # Portal entry
|
||||||
│ ├── src/resource-status/ # 設備即時概況 entry
|
│ ├── src/resource-shared/ # 設備雙頁共用 CSS/常數/元件
|
||||||
│ ├── src/resource-history/ # 設備歷史績效 entry
|
│ ├── src/resource-status/ # 設備即時概況 (Vue 3 SFC)
|
||||||
|
│ ├── src/resource-history/ # 設備歷史績效 (Vue 3 SFC)
|
||||||
│ ├── src/job-query/ # 設備維修查詢 entry
|
│ ├── src/job-query/ # 設備維修查詢 entry
|
||||||
│ ├── src/excel-query/ # Excel 批次查詢 entry
|
│ ├── src/excel-query/ # Excel 批次查詢 entry
|
||||||
│ ├── src/tables/ # 數據表查詢 entry (Vue 3 SFC)
|
│ ├── src/tables/ # 數據表查詢 entry (Vue 3 SFC)
|
||||||
@@ -732,6 +741,15 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce
|
|||||||
|
|
||||||
### 2026-02-09
|
### 2026-02-09
|
||||||
|
|
||||||
|
- 完成設備雙頁 Vue 3 遷移(`/resource`、`/resource-history`):
|
||||||
|
- 兩頁共 1,697 行 vanilla JS + 3,200 行 Jinja2 模板(含 820 行重複 inline fallback script)重寫為 Vue 3 SFC
|
||||||
|
- 抽取 `resource-shared/` 共用模組:CSS 基底(`:root` 變數、status 顏色、tree table 樣式)、常數(`STATUS_DISPLAY_MAP`、`STATUS_AGGREGATION`、`STATUS_COLORS`、`OU_BADGE_THRESHOLDS`)、`HierarchyTable.vue` 三層展開樹表元件
|
||||||
|
- Resource History:4 個 ECharts 圖表改用 vue-echarts(趨勢折線、堆疊柱狀、workcenter 對比、OU% 熱圖)
|
||||||
|
- Resource Status:複用 `wip-shared/composables/useAutoRefresh.js`(5 分鐘間隔)
|
||||||
|
- Resource Status:自訂 `FloatingTooltip.vue`(`<Teleport to="body">` + viewport clamp),顯示 LOT/JOB 詳情
|
||||||
|
- Resource History:`MultiSelect.vue` 多選下拉(checkbox list + click-outside + select all/clear)
|
||||||
|
- 兩頁 Vite entry 從 `main.js` 改為 `index.html`,Flask route 改為 `send_from_directory`
|
||||||
|
- 移除兩份 Jinja2 模板(`resource_status.html`、`resource_history.html`)
|
||||||
- 完成 WIP 三頁 Vue 3 遷移(`/wip-overview`、`/wip-detail`、`/hold-detail`):
|
- 完成 WIP 三頁 Vue 3 遷移(`/wip-overview`、`/wip-detail`、`/hold-detail`):
|
||||||
- 三頁共 1,941 行 vanilla JS + Jinja2 模板重寫為 Vue 3 SFC 元件架構
|
- 三頁共 1,941 行 vanilla JS + Jinja2 模板重寫為 Vue 3 SFC 元件架構
|
||||||
- 抽取 `wip-shared/` 共用模組:CSS 基底(`:root` 變數、gradient header、responsive)、常數(`NON_QUALITY_HOLD_REASONS` 11 值)、Pagination/FilterBar 元件
|
- 抽取 `wip-shared/` 共用模組:CSS 基底(`:root` 變數、gradient header、responsive)、常數(`NON_QUALITY_HOLD_REASONS` 11 值)、Pagination/FilterBar 元件
|
||||||
@@ -861,5 +879,5 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**文檔版本**: 5.2
|
**文檔版本**: 5.3
|
||||||
**最後更新**: 2026-02-09
|
**最後更新**: 2026-02-09
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html",
|
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html",
|
||||||
"test": "node --test tests/*.test.js"
|
"test": "node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
317
frontend/src/resource-history/App.vue
Normal file
317
frontend/src/resource-history/App.vue
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
||||||
|
import { buildResourceKpiFromHours } from '../core/compute.js';
|
||||||
|
|
||||||
|
import ComparisonChart from './components/ComparisonChart.vue';
|
||||||
|
import DetailSection from './components/DetailSection.vue';
|
||||||
|
import FilterBar from './components/FilterBar.vue';
|
||||||
|
import HeatmapChart from './components/HeatmapChart.vue';
|
||||||
|
import KpiCards from './components/KpiCards.vue';
|
||||||
|
import StackedChart from './components/StackedChart.vue';
|
||||||
|
import TrendChart from './components/TrendChart.vue';
|
||||||
|
|
||||||
|
ensureMesApiAvailable();
|
||||||
|
|
||||||
|
const API_TIMEOUT = 60000;
|
||||||
|
const MAX_QUERY_DAYS = 730;
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
granularity: 'day',
|
||||||
|
workcenterGroups: [],
|
||||||
|
families: [],
|
||||||
|
isProduction: false,
|
||||||
|
isKey: false,
|
||||||
|
isMonitor: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = reactive({
|
||||||
|
workcenterGroups: [],
|
||||||
|
families: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryData = ref({
|
||||||
|
kpi: {},
|
||||||
|
trend: [],
|
||||||
|
heatmap: [],
|
||||||
|
workcenter_comparison: [],
|
||||||
|
});
|
||||||
|
const detailData = ref([]);
|
||||||
|
const hierarchyState = reactive({});
|
||||||
|
|
||||||
|
const loading = reactive({
|
||||||
|
initial: true,
|
||||||
|
querying: false,
|
||||||
|
options: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryError = ref('');
|
||||||
|
const detailWarning = ref('');
|
||||||
|
const exportMessage = ref('');
|
||||||
|
|
||||||
|
function resetHierarchyState() {
|
||||||
|
Object.keys(hierarchyState).forEach((key) => {
|
||||||
|
delete hierarchyState[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultDates() {
|
||||||
|
const today = new Date();
|
||||||
|
const endDate = new Date(today);
|
||||||
|
endDate.setDate(endDate.getDate() - 1);
|
||||||
|
|
||||||
|
const startDate = new Date(endDate);
|
||||||
|
startDate.setDate(startDate.getDate() - 6);
|
||||||
|
|
||||||
|
filters.startDate = toDateString(startDate);
|
||||||
|
filters.endDate = toDateString(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateString(value) {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapApiResult(result, fallbackMessage) {
|
||||||
|
if (result?.success === true) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (result?.success === false) {
|
||||||
|
throw new Error(result.error || fallbackMessage);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeComputedKpi(source) {
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
...buildResourceKpiFromHours(source),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
params.append('start_date', filters.startDate);
|
||||||
|
params.append('end_date', filters.endDate);
|
||||||
|
params.append('granularity', filters.granularity);
|
||||||
|
|
||||||
|
filters.workcenterGroups.forEach((group) => {
|
||||||
|
params.append('workcenter_groups', group);
|
||||||
|
});
|
||||||
|
filters.families.forEach((family) => {
|
||||||
|
params.append('families', family);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters.isProduction) {
|
||||||
|
params.append('is_production', '1');
|
||||||
|
}
|
||||||
|
if (filters.isKey) {
|
||||||
|
params.append('is_key', '1');
|
||||||
|
}
|
||||||
|
if (filters.isMonitor) {
|
||||||
|
params.append('is_monitor', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDateRange() {
|
||||||
|
if (!filters.startDate || !filters.endDate) {
|
||||||
|
return '請先設定開始與結束日期';
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(filters.startDate);
|
||||||
|
const end = new Date(filters.endDate);
|
||||||
|
const diffDays = (end - start) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (diffDays < 0) {
|
||||||
|
return '結束日期必須大於起始日期';
|
||||||
|
}
|
||||||
|
if (diffDays > MAX_QUERY_DAYS) {
|
||||||
|
return '查詢範圍不可超過兩年';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
loading.options = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiGet('/api/resource/history/options', {
|
||||||
|
timeout: API_TIMEOUT,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = unwrapApiResult(response, '載入篩選選項失敗');
|
||||||
|
const data = payload.data || {};
|
||||||
|
|
||||||
|
options.workcenterGroups = Array.isArray(data.workcenter_groups) ? data.workcenter_groups : [];
|
||||||
|
options.families = Array.isArray(data.families) ? data.families : [];
|
||||||
|
} finally {
|
||||||
|
loading.options = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeQuery() {
|
||||||
|
const validationError = validateDateRange();
|
||||||
|
if (validationError) {
|
||||||
|
queryError.value = validationError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.querying = true;
|
||||||
|
queryError.value = '';
|
||||||
|
detailWarning.value = '';
|
||||||
|
exportMessage.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryString = buildQueryString();
|
||||||
|
const [summaryResponse, detailResponse] = await Promise.all([
|
||||||
|
apiGet(`/api/resource/history/summary?${queryString}`, {
|
||||||
|
timeout: API_TIMEOUT,
|
||||||
|
silent: true,
|
||||||
|
}),
|
||||||
|
apiGet(`/api/resource/history/detail?${queryString}`, {
|
||||||
|
timeout: API_TIMEOUT,
|
||||||
|
silent: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summaryPayload = unwrapApiResult(summaryResponse, '查詢摘要失敗');
|
||||||
|
const detailPayload = unwrapApiResult(detailResponse, '查詢明細失敗');
|
||||||
|
|
||||||
|
const rawSummary = summaryPayload.data || {};
|
||||||
|
summaryData.value = {
|
||||||
|
...rawSummary,
|
||||||
|
kpi: mergeComputedKpi(rawSummary.kpi || {}),
|
||||||
|
trend: (rawSummary.trend || []).map((item) => mergeComputedKpi(item || {})),
|
||||||
|
heatmap: rawSummary.heatmap || [],
|
||||||
|
workcenter_comparison: rawSummary.workcenter_comparison || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
detailData.value = Array.isArray(detailPayload.data) ? detailPayload.data : [];
|
||||||
|
resetHierarchyState();
|
||||||
|
|
||||||
|
if (detailPayload.truncated) {
|
||||||
|
detailWarning.value = `明細資料超過 ${detailPayload.max_records} 筆,僅顯示前 ${detailPayload.max_records} 筆。`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
queryError.value = error?.message || '查詢失敗';
|
||||||
|
summaryData.value = {
|
||||||
|
kpi: {},
|
||||||
|
trend: [],
|
||||||
|
heatmap: [],
|
||||||
|
workcenter_comparison: [],
|
||||||
|
};
|
||||||
|
detailData.value = [];
|
||||||
|
resetHierarchyState();
|
||||||
|
} finally {
|
||||||
|
loading.querying = false;
|
||||||
|
loading.initial = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilters(nextFilters) {
|
||||||
|
filters.startDate = nextFilters.startDate || '';
|
||||||
|
filters.endDate = nextFilters.endDate || '';
|
||||||
|
filters.granularity = nextFilters.granularity || 'day';
|
||||||
|
filters.workcenterGroups = Array.isArray(nextFilters.workcenterGroups)
|
||||||
|
? nextFilters.workcenterGroups
|
||||||
|
: [];
|
||||||
|
filters.families = Array.isArray(nextFilters.families) ? nextFilters.families : [];
|
||||||
|
filters.isProduction = Boolean(nextFilters.isProduction);
|
||||||
|
filters.isKey = Boolean(nextFilters.isKey);
|
||||||
|
filters.isMonitor = Boolean(nextFilters.isMonitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleRow(rowId) {
|
||||||
|
hierarchyState[rowId] = !hierarchyState[rowId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleAllRows({ expand, rowIds }) {
|
||||||
|
(rowIds || []).forEach((rowId) => {
|
||||||
|
hierarchyState[rowId] = Boolean(expand);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
if (!filters.startDate || !filters.endDate) {
|
||||||
|
queryError.value = '請先設定查詢條件';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = buildQueryString();
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `/api/resource/history/export?${queryString}`;
|
||||||
|
link.download = `resource_history_${filters.startDate}_to_${filters.endDate}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
exportMessage.value = 'CSV 匯出中...';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPage() {
|
||||||
|
setDefaultDates();
|
||||||
|
try {
|
||||||
|
await loadOptions();
|
||||||
|
} catch (error) {
|
||||||
|
queryError.value = error?.message || '載入篩選選項失敗';
|
||||||
|
}
|
||||||
|
await executeQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
void initPage();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="resource-page">
|
||||||
|
<div class="dashboard">
|
||||||
|
<header class="header-gradient history-header">
|
||||||
|
<h1>設備歷史績效</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
:filters="filters"
|
||||||
|
:options="options"
|
||||||
|
:loading="loading.options || loading.querying"
|
||||||
|
@update-filters="updateFilters"
|
||||||
|
@query="executeQuery"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="queryError" class="error-banner query-error">{{ queryError }}</p>
|
||||||
|
<p v-if="detailWarning" class="filter-indicator active">{{ detailWarning }}</p>
|
||||||
|
<p v-if="exportMessage" class="filter-indicator active">{{ exportMessage }}</p>
|
||||||
|
|
||||||
|
<KpiCards :kpi="summaryData.kpi" />
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="chart-grid">
|
||||||
|
<TrendChart :trend="summaryData.trend || []" />
|
||||||
|
<StackedChart :trend="summaryData.trend || []" />
|
||||||
|
<ComparisonChart :comparison="summaryData.workcenter_comparison || []" />
|
||||||
|
<HeatmapChart :heatmap="summaryData.heatmap || []" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DetailSection
|
||||||
|
:detail-data="detailData"
|
||||||
|
:expanded-state="hierarchyState"
|
||||||
|
:loading="loading.querying"
|
||||||
|
@toggle-row="handleToggleRow"
|
||||||
|
@toggle-all="handleToggleAllRows"
|
||||||
|
@export-csv="exportCsv"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-overlay" :class="{ hidden: !loading.initial && !loading.querying }">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
96
frontend/src/resource-history/components/ComparisonChart.vue
Normal file
96
frontend/src/resource-history/components/ComparisonChart.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { BarChart } from 'echarts/charts';
|
||||||
|
import { GridComponent, TooltipComponent } from 'echarts/components';
|
||||||
|
import { use } from 'echarts/core';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
import VChart from 'vue-echarts';
|
||||||
|
|
||||||
|
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent]);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
comparison: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankedData = computed(() => {
|
||||||
|
return [...(props.comparison || [])]
|
||||||
|
.sort((left, right) => Number(right.ou_pct || 0) - Number(left.ou_pct || 0))
|
||||||
|
.slice(0, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasData = computed(() => rankedData.value.length > 0);
|
||||||
|
|
||||||
|
const chartOption = computed(() => {
|
||||||
|
const rows = rankedData.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
formatter(params) {
|
||||||
|
if (!Array.isArray(params) || !params.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const idx = Number(params[0].dataIndex || 0);
|
||||||
|
const row = rows[idx] || {};
|
||||||
|
return `${row.workcenter || '--'}<br/>OU%: <b>${Number(row.ou_pct || 0).toFixed(1)}%</b><br/>機台數: ${
|
||||||
|
Number(row.machine_count || 0)
|
||||||
|
}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 110,
|
||||||
|
right: 24,
|
||||||
|
top: 20,
|
||||||
|
bottom: 24,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
inverse: true,
|
||||||
|
data: rows.map((item) => item.workcenter),
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 20,
|
||||||
|
data: rows.map((item) => ({
|
||||||
|
value: Number(item.ou_pct || 0),
|
||||||
|
itemStyle: {
|
||||||
|
color:
|
||||||
|
Number(item.ou_pct || 0) >= 80
|
||||||
|
? '#22c55e'
|
||||||
|
: Number(item.ou_pct || 0) >= 50
|
||||||
|
? '#f59e0b'
|
||||||
|
: '#ef4444',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="chart-card">
|
||||||
|
<h3 class="chart-title">Top 15 Workcenter OU%</h3>
|
||||||
|
<div v-if="hasData" class="chart-body">
|
||||||
|
<VChart :option="chartOption" autoresize />
|
||||||
|
</div>
|
||||||
|
<div v-else class="chart-no-data">No data</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
247
frontend/src/resource-history/components/DetailSection.vue
Normal file
247
frontend/src/resource-history/components/DetailSection.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { buildResourceKpiFromHours } from '../../core/compute.js';
|
||||||
|
import HierarchyTable from '../../resource-shared/components/HierarchyTable.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
detailData: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
expandedState: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle-row', 'toggle-all', 'export-csv']);
|
||||||
|
|
||||||
|
function normalizeKey(value) {
|
||||||
|
return String(value || 'unknown').replace(/[^\w\u4e00-\u9fa5-]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHourBucket() {
|
||||||
|
return {
|
||||||
|
prd_hours: 0,
|
||||||
|
sby_hours: 0,
|
||||||
|
udt_hours: 0,
|
||||||
|
sdt_hours: 0,
|
||||||
|
egt_hours: 0,
|
||||||
|
nst_hours: 0,
|
||||||
|
machine_count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeHours(target, source) {
|
||||||
|
target.prd_hours += Number(source.prd_hours || 0);
|
||||||
|
target.sby_hours += Number(source.sby_hours || 0);
|
||||||
|
target.udt_hours += Number(source.udt_hours || 0);
|
||||||
|
target.sdt_hours += Number(source.sdt_hours || 0);
|
||||||
|
target.egt_hours += Number(source.egt_hours || 0);
|
||||||
|
target.nst_hours += Number(source.nst_hours || 0);
|
||||||
|
target.machine_count += Number(source.machine_count || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichWithKpi(hours) {
|
||||||
|
return {
|
||||||
|
...hours,
|
||||||
|
...buildResourceKpiFromHours(hours),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHierarchy(data) {
|
||||||
|
const wcMap = new Map();
|
||||||
|
|
||||||
|
(data || []).forEach((item, index) => {
|
||||||
|
const workcenter = item.workcenter || 'UNKNOWN';
|
||||||
|
const family = item.family || 'UNKNOWN';
|
||||||
|
const resourceName = item.resource || item.HISTORYID || `RESOURCE_${index + 1}`;
|
||||||
|
const sequence = Number(item.workcenter_seq ?? 999);
|
||||||
|
|
||||||
|
if (!wcMap.has(workcenter)) {
|
||||||
|
wcMap.set(workcenter, {
|
||||||
|
id: `wc_${normalizeKey(workcenter)}`,
|
||||||
|
level: 0,
|
||||||
|
name: workcenter,
|
||||||
|
workcenter,
|
||||||
|
sequence,
|
||||||
|
metrics: createHourBucket(),
|
||||||
|
children: [],
|
||||||
|
familyMap: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const wcNode = wcMap.get(workcenter);
|
||||||
|
|
||||||
|
if (!wcNode.familyMap.has(family)) {
|
||||||
|
const familyNode = {
|
||||||
|
id: `fam_${normalizeKey(workcenter)}_${normalizeKey(family)}`,
|
||||||
|
level: 1,
|
||||||
|
name: family,
|
||||||
|
workcenter,
|
||||||
|
family,
|
||||||
|
metrics: createHourBucket(),
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
wcNode.familyMap.set(family, familyNode);
|
||||||
|
wcNode.children.push(familyNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const familyNode = wcNode.familyMap.get(family);
|
||||||
|
|
||||||
|
const resourceMetrics = enrichWithKpi({
|
||||||
|
prd_hours: Number(item.prd_hours || 0),
|
||||||
|
sby_hours: Number(item.sby_hours || 0),
|
||||||
|
udt_hours: Number(item.udt_hours || 0),
|
||||||
|
sdt_hours: Number(item.sdt_hours || 0),
|
||||||
|
egt_hours: Number(item.egt_hours || 0),
|
||||||
|
nst_hours: Number(item.nst_hours || 0),
|
||||||
|
machine_count: Number(item.machine_count || 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
familyNode.children.push({
|
||||||
|
id: `res_${normalizeKey(workcenter)}_${normalizeKey(family)}_${normalizeKey(resourceName)}_${index}`,
|
||||||
|
level: 2,
|
||||||
|
name: resourceName,
|
||||||
|
metrics: resourceMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
mergeHours(familyNode.metrics, resourceMetrics);
|
||||||
|
mergeHours(wcNode.metrics, resourceMetrics);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...wcMap.values()]
|
||||||
|
.map((wcNode) => {
|
||||||
|
wcNode.metrics = enrichWithKpi(wcNode.metrics);
|
||||||
|
|
||||||
|
wcNode.children.sort((left, right) => {
|
||||||
|
const diff = Number(right.metrics.machine_count || 0) - Number(left.metrics.machine_count || 0);
|
||||||
|
if (diff !== 0) {
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
|
return String(left.name).localeCompare(String(right.name), 'zh-Hant');
|
||||||
|
});
|
||||||
|
|
||||||
|
wcNode.children.forEach((familyNode) => {
|
||||||
|
familyNode.metrics = enrichWithKpi(familyNode.metrics);
|
||||||
|
});
|
||||||
|
|
||||||
|
delete wcNode.familyMap;
|
||||||
|
return wcNode;
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const seqDiff = Number(left.sequence || 999) - Number(right.sequence || 999);
|
||||||
|
if (seqDiff !== 0) {
|
||||||
|
return seqDiff;
|
||||||
|
}
|
||||||
|
return String(left.name).localeCompare(String(right.name), 'zh-Hant');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHourPct(hours, pct) {
|
||||||
|
return `${Number(hours || 0).toFixed(1)}h (${Number(pct || 0).toFixed(1)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchy = computed(() => buildHierarchy(props.detailData));
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'ou',
|
||||||
|
label: 'OU%',
|
||||||
|
value: (node) => `${Number(node.metrics?.ou_pct || 0).toFixed(1)}%`,
|
||||||
|
className: 'col-total',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'availability',
|
||||||
|
label: 'AVAIL%',
|
||||||
|
value: (node) => `${Number(node.metrics?.availability_pct || 0).toFixed(1)}%`,
|
||||||
|
className: 'col-total',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'PRD',
|
||||||
|
label: 'PRD',
|
||||||
|
className: 'col-prd detail-cell',
|
||||||
|
value: (node) => formatHourPct(node.metrics?.prd_hours, node.metrics?.prd_pct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SBY',
|
||||||
|
label: 'SBY',
|
||||||
|
className: 'col-sby detail-cell',
|
||||||
|
value: (node) => formatHourPct(node.metrics?.sby_hours, node.metrics?.sby_pct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'UDT',
|
||||||
|
label: 'UDT',
|
||||||
|
className: 'col-udt detail-cell',
|
||||||
|
value: (node) => formatHourPct(node.metrics?.udt_hours, node.metrics?.udt_pct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SDT',
|
||||||
|
label: 'SDT',
|
||||||
|
className: 'col-sdt detail-cell',
|
||||||
|
value: (node) => formatHourPct(node.metrics?.sdt_hours, node.metrics?.sdt_pct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'EGT',
|
||||||
|
label: 'EGT',
|
||||||
|
className: 'col-egt detail-cell',
|
||||||
|
value: (node) => formatHourPct(node.metrics?.egt_hours, node.metrics?.egt_pct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'NST',
|
||||||
|
label: 'NST',
|
||||||
|
className: 'col-nst detail-cell',
|
||||||
|
value: (node) => formatHourPct(node.metrics?.nst_hours, node.metrics?.nst_pct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'count',
|
||||||
|
label: 'Count',
|
||||||
|
className: 'col-total',
|
||||||
|
value: (node) => Number(node.metrics?.machine_count || 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleToggleAll(expand) {
|
||||||
|
const rowIds = [];
|
||||||
|
hierarchy.value.forEach((wcNode) => {
|
||||||
|
rowIds.push(wcNode.id);
|
||||||
|
wcNode.children.forEach((familyNode) => {
|
||||||
|
rowIds.push(familyNode.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('toggle-all', { expand, rowIds });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section-card">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">明細資料</h2>
|
||||||
|
<div class="detail-toolbar">
|
||||||
|
<button type="button" class="btn-sm" :disabled="loading" @click="handleToggleAll(true)">全部展開</button>
|
||||||
|
<button type="button" class="btn-sm" :disabled="loading" @click="handleToggleAll(false)">全部收合</button>
|
||||||
|
<button type="button" class="btn-sm" :disabled="loading" @click="$emit('export-csv')">匯出 CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HierarchyTable
|
||||||
|
:hierarchy="hierarchy"
|
||||||
|
:columns="columns"
|
||||||
|
:expanded-state="expandedState"
|
||||||
|
name-column-label="工站 / 型號 / 設備"
|
||||||
|
empty-text="No data"
|
||||||
|
@toggle-row="$emit('toggle-row', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
138
frontend/src/resource-history/components/FilterBar.vue
Normal file
138
frontend/src/resource-history/components/FilterBar.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup>
|
||||||
|
import MultiSelect from './MultiSelect.vue';
|
||||||
|
|
||||||
|
const GRANULARITY_ITEMS = [
|
||||||
|
{ key: 'day', label: '日' },
|
||||||
|
{ key: 'week', label: '週' },
|
||||||
|
{ key: 'month', label: '月' },
|
||||||
|
{ key: 'year', label: '年' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
filters: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
workcenterGroups: [],
|
||||||
|
families: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update-filters', 'query']);
|
||||||
|
|
||||||
|
function updateFilters(patch) {
|
||||||
|
emit('update-filters', {
|
||||||
|
...props.filters,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section-card">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="filter-field">
|
||||||
|
<label for="history-start-date">開始</label>
|
||||||
|
<input
|
||||||
|
id="history-start-date"
|
||||||
|
type="date"
|
||||||
|
:value="filters.startDate"
|
||||||
|
:disabled="loading"
|
||||||
|
@input="updateFilters({ startDate: $event.target.value })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-field">
|
||||||
|
<label for="history-end-date">結束</label>
|
||||||
|
<input
|
||||||
|
id="history-end-date"
|
||||||
|
type="date"
|
||||||
|
:value="filters.endDate"
|
||||||
|
:disabled="loading"
|
||||||
|
@input="updateFilters({ endDate: $event.target.value })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-field">
|
||||||
|
<label>粒度</label>
|
||||||
|
<div class="granularity-btns">
|
||||||
|
<button
|
||||||
|
v-for="item in GRANULARITY_ITEMS"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="granularity-btn"
|
||||||
|
:class="{ active: filters.granularity === item.key }"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="updateFilters({ granularity: item.key })"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-field">
|
||||||
|
<label>工站群組</label>
|
||||||
|
<MultiSelect
|
||||||
|
:model-value="filters.workcenterGroups"
|
||||||
|
:options="options.workcenterGroups"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="全部站點"
|
||||||
|
@update:model-value="updateFilters({ workcenterGroups: $event })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-field">
|
||||||
|
<label>型號</label>
|
||||||
|
<MultiSelect
|
||||||
|
:model-value="filters.families"
|
||||||
|
:options="options.families"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="全部型號"
|
||||||
|
@update:model-value="updateFilters({ families: $event })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<label class="checkbox-pill">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="filters.isProduction"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="updateFilters({ isProduction: $event.target.checked })"
|
||||||
|
/>
|
||||||
|
生產設備
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-pill">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="filters.isKey"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="updateFilters({ isKey: $event.target.checked })"
|
||||||
|
/>
|
||||||
|
重點設備
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-pill">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="filters.isMonitor"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="updateFilters({ isMonitor: $event.target.checked })"
|
||||||
|
/>
|
||||||
|
監控設備
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="loading" @click="$emit('query')">查詢</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
120
frontend/src/resource-history/components/HeatmapChart.vue
Normal file
120
frontend/src/resource-history/components/HeatmapChart.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { HeatmapChart } from 'echarts/charts';
|
||||||
|
import { GridComponent, TooltipComponent, VisualMapComponent } from 'echarts/components';
|
||||||
|
import { use } from 'echarts/core';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
import VChart from 'vue-echarts';
|
||||||
|
|
||||||
|
use([CanvasRenderer, HeatmapChart, GridComponent, TooltipComponent, VisualMapComponent]);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
heatmap: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasData = computed(() => props.heatmap.length > 0);
|
||||||
|
|
||||||
|
const parsedHeatmap = computed(() => {
|
||||||
|
const rows = props.heatmap || [];
|
||||||
|
|
||||||
|
const seqByWorkcenter = {};
|
||||||
|
rows.forEach((row) => {
|
||||||
|
seqByWorkcenter[row.workcenter] = Number(row.workcenter_seq ?? 999);
|
||||||
|
});
|
||||||
|
|
||||||
|
const workcenters = [...new Set(rows.map((row) => row.workcenter))].sort(
|
||||||
|
(left, right) => Number(seqByWorkcenter[left] ?? 999) - Number(seqByWorkcenter[right] ?? 999)
|
||||||
|
);
|
||||||
|
const dates = [...new Set(rows.map((row) => row.date))].sort();
|
||||||
|
|
||||||
|
const matrixData = rows.map((row) => [
|
||||||
|
dates.indexOf(row.date),
|
||||||
|
workcenters.indexOf(row.workcenter),
|
||||||
|
Number(row.ou_pct || 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dates,
|
||||||
|
workcenters,
|
||||||
|
matrixData,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOption = computed(() => {
|
||||||
|
const payload = parsedHeatmap.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
position: 'top',
|
||||||
|
formatter(params) {
|
||||||
|
const xIndex = Number(params.value?.[0] || 0);
|
||||||
|
const yIndex = Number(params.value?.[1] || 0);
|
||||||
|
const value = Number(params.value?.[2] || 0);
|
||||||
|
|
||||||
|
return `${payload.workcenters[yIndex] || '--'}<br/>${payload.dates[xIndex] || '--'}<br/>OU%: <b>${value.toFixed(
|
||||||
|
1
|
||||||
|
)}%</b>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 110,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: 64,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: payload.dates,
|
||||||
|
splitArea: { show: true },
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
rotate: 40,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: payload.workcenters,
|
||||||
|
splitArea: { show: true },
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
visualMap: {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
orient: 'horizontal',
|
||||||
|
left: 'center',
|
||||||
|
bottom: 10,
|
||||||
|
inRange: {
|
||||||
|
color: ['#ef4444', '#f59e0b', '#22c55e'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'heatmap',
|
||||||
|
data: payload.matrixData,
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="chart-card">
|
||||||
|
<h3 class="chart-title">Workcenter x Date OU% 熱圖</h3>
|
||||||
|
<div v-if="hasData" class="chart-body">
|
||||||
|
<VChart :option="chartOption" autoresize />
|
||||||
|
</div>
|
||||||
|
<div v-else class="chart-no-data">No data</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
124
frontend/src/resource-history/components/KpiCards.vue
Normal file
124
frontend/src/resource-history/components/KpiCards.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { buildResourceKpiFromHours } from '../../core/compute.js';
|
||||||
|
import { OU_BADGE_THRESHOLDS } from '../../resource-shared/constants.js';
|
||||||
|
|
||||||
|
function resolveOuClass(value) {
|
||||||
|
const pct = Number(value || 0);
|
||||||
|
if (pct >= OU_BADGE_THRESHOLDS.high) return 'high';
|
||||||
|
if (pct >= OU_BADGE_THRESHOLDS.medium) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
kpi: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatHours(value) {
|
||||||
|
const hours = Number(value || 0);
|
||||||
|
if (hours >= 1000) {
|
||||||
|
return `${(hours / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return `${hours.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKpi = computed(() => {
|
||||||
|
return {
|
||||||
|
...props.kpi,
|
||||||
|
...buildResourceKpiFromHours(props.kpi),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const cards = computed(() => {
|
||||||
|
const kpi = normalizedKpi.value;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'ou',
|
||||||
|
label: 'OU%',
|
||||||
|
value: `${Number(kpi.ou_pct || 0).toFixed(1)}%`,
|
||||||
|
className: 'ou',
|
||||||
|
sub: '稼動率',
|
||||||
|
badgeClass: resolveOuClass(kpi.ou_pct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'availability',
|
||||||
|
label: 'AVAIL%',
|
||||||
|
value: `${Number(kpi.availability_pct || 0).toFixed(1)}%`,
|
||||||
|
className: 'availability',
|
||||||
|
sub: '可用率',
|
||||||
|
badgeClass: resolveOuClass(kpi.availability_pct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prd',
|
||||||
|
label: 'PRD',
|
||||||
|
value: formatHours(kpi.prd_hours),
|
||||||
|
className: 'prd',
|
||||||
|
sub: `生產 (${Number(kpi.prd_pct || 0).toFixed(1)}%)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sby',
|
||||||
|
label: 'SBY',
|
||||||
|
value: formatHours(kpi.sby_hours),
|
||||||
|
className: 'sby',
|
||||||
|
sub: `待機 (${Number(kpi.sby_pct || 0).toFixed(1)}%)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'udt',
|
||||||
|
label: 'UDT',
|
||||||
|
value: formatHours(kpi.udt_hours),
|
||||||
|
className: 'udt',
|
||||||
|
sub: `非計畫停機 (${Number(kpi.udt_pct || 0).toFixed(1)}%)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sdt',
|
||||||
|
label: 'SDT',
|
||||||
|
value: formatHours(kpi.sdt_hours),
|
||||||
|
className: 'sdt',
|
||||||
|
sub: `計畫停機 (${Number(kpi.sdt_pct || 0).toFixed(1)}%)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'egt',
|
||||||
|
label: 'EGT',
|
||||||
|
value: formatHours(kpi.egt_hours),
|
||||||
|
className: 'egt',
|
||||||
|
sub: `工程 (${Number(kpi.egt_pct || 0).toFixed(1)}%)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nst',
|
||||||
|
label: 'NST',
|
||||||
|
value: formatHours(kpi.nst_hours),
|
||||||
|
className: 'nst',
|
||||||
|
sub: `未排程 (${Number(kpi.nst_pct || 0).toFixed(1)}%)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'machine',
|
||||||
|
label: '機台數',
|
||||||
|
value: Number(kpi.machine_count || 0).toLocaleString('zh-TW'),
|
||||||
|
className: 'total',
|
||||||
|
sub: '設備總數',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section-card">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="summary-grid">
|
||||||
|
<article v-for="card in cards" :key="card.key" class="summary-card" :class="card.className">
|
||||||
|
<div class="summary-label">{{ card.label }}</div>
|
||||||
|
<div class="summary-value" :class="card.className">
|
||||||
|
<span v-if="card.badgeClass" class="ou-badge" :class="card.badgeClass">{{ card.value }}</span>
|
||||||
|
<template v-else>{{ card.value }}</template>
|
||||||
|
</div>
|
||||||
|
<div class="summary-sub">{{ card.sub }}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
154
frontend/src/resource-history/components/MultiSelect.vue
Normal file
154
frontend/src/resource-history/components/MultiSelect.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '請選擇',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const rootRef = ref(null);
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
const normalizedOptions = computed(() => {
|
||||||
|
return props.options.map((option) => {
|
||||||
|
if (option && typeof option === 'object') {
|
||||||
|
const value = option.value ?? option.name ?? option.label ?? '';
|
||||||
|
const label = option.label ?? option.name ?? option.value ?? '';
|
||||||
|
return {
|
||||||
|
label: String(label),
|
||||||
|
value: String(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: String(option),
|
||||||
|
value: String(option),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSet = computed(() => new Set((props.modelValue || []).map((value) => String(value))));
|
||||||
|
|
||||||
|
const selectedText = computed(() => {
|
||||||
|
if (!props.modelValue.length) {
|
||||||
|
return props.placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.modelValue.length === 1) {
|
||||||
|
const found = normalizedOptions.value.find(
|
||||||
|
(option) => option.value === String(props.modelValue[0])
|
||||||
|
);
|
||||||
|
return found?.label || String(props.modelValue[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `已選 ${props.modelValue.length} 項`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDropdown() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isOpen.value = !isOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(value) {
|
||||||
|
return selectedSet.value.has(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOption(value) {
|
||||||
|
const next = new Set(selectedSet.value);
|
||||||
|
const key = String(value);
|
||||||
|
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', [...next]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
normalizedOptions.value.map((option) => option.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
emit('update:modelValue', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutsideClick(event) {
|
||||||
|
if (!isOpen.value || !rootRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rootRef.value.contains(event.target)) {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleOutsideClick, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleOutsideClick, true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="multi-select">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="multi-select-trigger"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="toggleDropdown"
|
||||||
|
>
|
||||||
|
<span class="multi-select-text">{{ selectedText }}</span>
|
||||||
|
<span class="multi-select-arrow">{{ isOpen ? '▲' : '▼' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="isOpen" class="multi-select-dropdown">
|
||||||
|
<div class="multi-select-options">
|
||||||
|
<button
|
||||||
|
v-for="option in normalizedOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="multi-select-option"
|
||||||
|
@click="toggleOption(option.value)"
|
||||||
|
>
|
||||||
|
<input type="checkbox" :checked="isSelected(option.value)" tabindex="-1" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="multi-select-actions">
|
||||||
|
<button type="button" class="btn-sm" @click="selectAll">全選</button>
|
||||||
|
<button type="button" class="btn-sm" @click="clearAll">清除</button>
|
||||||
|
<button type="button" class="btn-sm" @click="closeDropdown">關閉</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
97
frontend/src/resource-history/components/StackedChart.vue
Normal file
97
frontend/src/resource-history/components/StackedChart.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { BarChart } from 'echarts/charts';
|
||||||
|
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
|
||||||
|
import { use } from 'echarts/core';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
import VChart from 'vue-echarts';
|
||||||
|
|
||||||
|
import { STATUS_COLORS } from '../../resource-shared/constants.js';
|
||||||
|
|
||||||
|
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
trend: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasData = computed(() => props.trend.length > 0);
|
||||||
|
|
||||||
|
const statuses = ['PRD', 'SBY', 'UDT', 'SDT', 'EGT', 'NST'];
|
||||||
|
|
||||||
|
const chartOption = computed(() => {
|
||||||
|
const trend = props.trend || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
formatter(params) {
|
||||||
|
if (!Array.isArray(params) || !params.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = Number(params[0].dataIndex || 0);
|
||||||
|
const current = trend[index] || {};
|
||||||
|
const total = statuses.reduce(
|
||||||
|
(sum, status) => sum + Number(current[`${status.toLowerCase()}_hours`] || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = params.map((item) => {
|
||||||
|
const value = Number(item.value || 0);
|
||||||
|
const pct = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
|
||||||
|
return `${item.marker}${item.seriesName}: ${value.toFixed(1)}h (${pct}%)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [`<b>${current.date || '--'}</b>`, ...lines, `<b>Total: ${total.toFixed(1)}h</b>`].join('<br/>');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: statuses,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 46,
|
||||||
|
right: 20,
|
||||||
|
top: 24,
|
||||||
|
bottom: 60,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: trend.map((item) => item.date),
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}h',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: statuses.map((status) => ({
|
||||||
|
name: status,
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'hours',
|
||||||
|
itemStyle: {
|
||||||
|
color: STATUS_COLORS[status],
|
||||||
|
},
|
||||||
|
data: trend.map((item) => Number(item[`${status.toLowerCase()}_hours`] || 0)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="chart-card">
|
||||||
|
<h3 class="chart-title">E10 狀態時數分布</h3>
|
||||||
|
<div v-if="hasData" class="chart-body">
|
||||||
|
<VChart :option="chartOption" autoresize />
|
||||||
|
</div>
|
||||||
|
<div v-else class="chart-no-data">No data</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
88
frontend/src/resource-history/components/TrendChart.vue
Normal file
88
frontend/src/resource-history/components/TrendChart.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { LineChart } from 'echarts/charts';
|
||||||
|
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
|
||||||
|
import { use } from 'echarts/core';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
import VChart from 'vue-echarts';
|
||||||
|
|
||||||
|
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
trend: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasData = computed(() => props.trend.length > 0);
|
||||||
|
|
||||||
|
const chartOption = computed(() => {
|
||||||
|
const trend = props.trend || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'line' },
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ['OU%', 'AVAIL%'],
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 46,
|
||||||
|
right: 20,
|
||||||
|
top: 24,
|
||||||
|
bottom: 50,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: trend.map((item) => item.date),
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'OU%',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbolSize: 6,
|
||||||
|
areaStyle: { opacity: 0.2 },
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
itemStyle: { color: '#2563eb' },
|
||||||
|
data: trend.map((item) => Number(item.ou_pct || 0)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'AVAIL%',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbolSize: 6,
|
||||||
|
areaStyle: { opacity: 0.2 },
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
itemStyle: { color: '#16a34a' },
|
||||||
|
data: trend.map((item) => Number(item.availability_pct || 0)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="chart-card">
|
||||||
|
<h3 class="chart-title">OU% / AVAIL% 趨勢</h3>
|
||||||
|
<div v-if="hasData" class="chart-body">
|
||||||
|
<VChart :option="chartOption" autoresize />
|
||||||
|
</div>
|
||||||
|
<div v-else class="chart-no-data">No data</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
12
frontend/src/resource-history/index.html
Normal file
12
frontend/src/resource-history/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>設備歷史績效</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,844 +1,7 @@
|
|||||||
import { ensureMesApiAvailable } from '../core/api.js';
|
import { createApp } from 'vue';
|
||||||
import { getPageContract } from '../core/field-contracts.js';
|
|
||||||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
|
||||||
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
|
|
||||||
|
|
||||||
ensureMesApiAvailable();
|
import App from './App.vue';
|
||||||
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
|
import '../resource-shared/styles.css';
|
||||||
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
|
import './style.css';
|
||||||
window.__FIELD_CONTRACTS__['resource_history:detail_table'] = getPageContract('resource_history', 'detail_table');
|
|
||||||
window.__FIELD_CONTRACTS__['resource_history:kpi'] = getPageContract('resource_history', 'kpi');
|
|
||||||
|
|
||||||
const detailTableFields = getPageContract('resource_history', 'detail_table');
|
createApp(App).mount('#app');
|
||||||
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
// ============================================================
|
|
||||||
// State
|
|
||||||
// ============================================================
|
|
||||||
let currentGranularity = 'day';
|
|
||||||
let summaryData = null;
|
|
||||||
let detailData = null;
|
|
||||||
let hierarchyState = {}; // Track expanded/collapsed state
|
|
||||||
let charts = {};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// DOM Elements
|
|
||||||
// ============================================================
|
|
||||||
const startDateInput = document.getElementById('startDate');
|
|
||||||
const endDateInput = document.getElementById('endDate');
|
|
||||||
const workcenterGroupsTrigger = document.getElementById('workcenterGroupsTrigger');
|
|
||||||
const workcenterGroupsDropdown = document.getElementById('workcenterGroupsDropdown');
|
|
||||||
const workcenterGroupsOptions = document.getElementById('workcenterGroupsOptions');
|
|
||||||
const familiesTrigger = document.getElementById('familiesTrigger');
|
|
||||||
const familiesDropdown = document.getElementById('familiesDropdown');
|
|
||||||
const familiesOptions = document.getElementById('familiesOptions');
|
|
||||||
const isProductionCheckbox = document.getElementById('isProduction');
|
|
||||||
const isKeyCheckbox = document.getElementById('isKey');
|
|
||||||
const isMonitorCheckbox = document.getElementById('isMonitor');
|
|
||||||
const queryBtn = document.getElementById('queryBtn');
|
|
||||||
const exportBtn = document.getElementById('exportBtn');
|
|
||||||
const expandAllBtn = document.getElementById('expandAllBtn');
|
|
||||||
const collapseAllBtn = document.getElementById('collapseAllBtn');
|
|
||||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
|
||||||
|
|
||||||
// Selected values for multi-select
|
|
||||||
let selectedWorkcenterGroups = [];
|
|
||||||
let selectedFamilies = [];
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Initialization
|
|
||||||
// ============================================================
|
|
||||||
function init() {
|
|
||||||
setDefaultDates();
|
|
||||||
applyDetailTableHeaders();
|
|
||||||
loadFilterOptions();
|
|
||||||
setupEventListeners();
|
|
||||||
initCharts();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDefaultDates() {
|
|
||||||
const today = new Date();
|
|
||||||
const endDate = new Date(today);
|
|
||||||
endDate.setDate(endDate.getDate() - 1); // Yesterday
|
|
||||||
const startDate = new Date(endDate);
|
|
||||||
startDate.setDate(startDate.getDate() - 6); // 7 days ago
|
|
||||||
|
|
||||||
startDateInput.value = formatDate(startDate);
|
|
||||||
endDateInput.value = formatDate(endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(date) {
|
|
||||||
return date.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupEventListeners() {
|
|
||||||
// Granularity buttons
|
|
||||||
document.querySelectorAll('.granularity-btns button').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('.granularity-btns button').forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
currentGranularity = btn.dataset.granularity;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query button
|
|
||||||
queryBtn.addEventListener('click', executeQuery);
|
|
||||||
|
|
||||||
// Export button
|
|
||||||
exportBtn.addEventListener('click', exportCsv);
|
|
||||||
|
|
||||||
// Expand/Collapse buttons
|
|
||||||
expandAllBtn.addEventListener('click', () => toggleAllRows(true));
|
|
||||||
collapseAllBtn.addEventListener('click', () => toggleAllRows(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDetailTableHeaders() {
|
|
||||||
const headers = document.querySelectorAll('.detail-table thead th');
|
|
||||||
if (!headers || headers.length < 10) return;
|
|
||||||
|
|
||||||
const byKey = {};
|
|
||||||
detailTableFields.forEach((field) => {
|
|
||||||
byKey[field.api_key] = field.ui_label;
|
|
||||||
});
|
|
||||||
|
|
||||||
headers[1].textContent = byKey.ou_pct || headers[1].textContent;
|
|
||||||
headers[2].textContent = byKey.availability_pct || headers[2].textContent;
|
|
||||||
headers[3].textContent = byKey.prd_hours ? byKey.prd_hours.replace('(h)', '') : headers[3].textContent;
|
|
||||||
headers[4].textContent = byKey.sby_hours ? byKey.sby_hours.replace('(h)', '') : headers[4].textContent;
|
|
||||||
headers[5].textContent = byKey.udt_hours ? byKey.udt_hours.replace('(h)', '') : headers[5].textContent;
|
|
||||||
headers[6].textContent = byKey.sdt_hours ? byKey.sdt_hours.replace('(h)', '') : headers[6].textContent;
|
|
||||||
headers[7].textContent = byKey.egt_hours ? byKey.egt_hours.replace('(h)', '') : headers[7].textContent;
|
|
||||||
headers[8].textContent = byKey.nst_hours ? byKey.nst_hours.replace('(h)', '') : headers[8].textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initCharts() {
|
|
||||||
charts.trend = echarts.init(document.getElementById('trendChart'));
|
|
||||||
charts.stacked = echarts.init(document.getElementById('stackedChart'));
|
|
||||||
charts.comparison = echarts.init(document.getElementById('comparisonChart'));
|
|
||||||
charts.heatmap = echarts.init(document.getElementById('heatmapChart'));
|
|
||||||
|
|
||||||
// Handle window resize
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
Object.values(charts).forEach(chart => chart.resize());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// API Calls (using MesApi client with timeout and retry)
|
|
||||||
// ============================================================
|
|
||||||
const API_TIMEOUT = 60000; // 60 seconds timeout
|
|
||||||
|
|
||||||
async function loadFilterOptions() {
|
|
||||||
try {
|
|
||||||
const result = await MesApi.get('/api/resource/history/options', {
|
|
||||||
timeout: API_TIMEOUT,
|
|
||||||
silent: true // Don't show toast for filter options
|
|
||||||
});
|
|
||||||
if (result.success) {
|
|
||||||
populateMultiSelect(workcenterGroupsOptions, result.data.workcenter_groups, 'workcenter');
|
|
||||||
populateMultiSelect(familiesOptions, result.data.families.map(f => ({name: f})), 'family');
|
|
||||||
setupMultiSelectDropdowns();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load filter options:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateMultiSelect(container, options, type) {
|
|
||||||
container.innerHTML = '';
|
|
||||||
options.forEach(opt => {
|
|
||||||
const name = opt.name || opt;
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'multi-select-option';
|
|
||||||
div.innerHTML = `
|
|
||||||
<input type="checkbox" value="${name}" data-type="${type}">
|
|
||||||
<span>${name}</span>
|
|
||||||
`;
|
|
||||||
div.querySelector('input').addEventListener('change', (e) => {
|
|
||||||
if (type === 'workcenter') {
|
|
||||||
updateSelectedWorkcenterGroups();
|
|
||||||
} else {
|
|
||||||
updateSelectedFamilies();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
container.appendChild(div);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupMultiSelectDropdowns() {
|
|
||||||
// Workcenter Groups dropdown toggle
|
|
||||||
workcenterGroupsTrigger.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
workcenterGroupsDropdown.classList.toggle('show');
|
|
||||||
familiesDropdown.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Families dropdown toggle
|
|
||||||
familiesTrigger.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
familiesDropdown.classList.toggle('show');
|
|
||||||
workcenterGroupsDropdown.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdowns when clicking outside
|
|
||||||
document.addEventListener('click', () => {
|
|
||||||
workcenterGroupsDropdown.classList.remove('show');
|
|
||||||
familiesDropdown.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent dropdown close when clicking inside
|
|
||||||
workcenterGroupsDropdown.addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
familiesDropdown.addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectedWorkcenterGroups() {
|
|
||||||
const checkboxes = workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]:checked');
|
|
||||||
selectedWorkcenterGroups = Array.from(checkboxes).map(cb => cb.value);
|
|
||||||
updateMultiSelectText(workcenterGroupsTrigger, selectedWorkcenterGroups, '全部站點');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectedFamilies() {
|
|
||||||
const checkboxes = familiesOptions.querySelectorAll('input[type="checkbox"]:checked');
|
|
||||||
selectedFamilies = Array.from(checkboxes).map(cb => cb.value);
|
|
||||||
updateMultiSelectText(familiesTrigger, selectedFamilies, '全部型號');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMultiSelectText(trigger, selected, defaultText) {
|
|
||||||
const textSpan = trigger.querySelector('.multi-select-text');
|
|
||||||
if (selected.length === 0) {
|
|
||||||
textSpan.textContent = defaultText;
|
|
||||||
} else if (selected.length === 1) {
|
|
||||||
textSpan.textContent = selected[0];
|
|
||||||
} else {
|
|
||||||
textSpan.textContent = `已選 ${selected.length} 項`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global functions for select all / clear all
|
|
||||||
window.selectAllWorkcenterGroups = function() {
|
|
||||||
workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
|
|
||||||
updateSelectedWorkcenterGroups();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.clearAllWorkcenterGroups = function() {
|
|
||||||
workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
|
||||||
updateSelectedWorkcenterGroups();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.selectAllFamilies = function() {
|
|
||||||
familiesOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
|
|
||||||
updateSelectedFamilies();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.clearAllFamilies = function() {
|
|
||||||
familiesOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
|
||||||
updateSelectedFamilies();
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildQueryString() {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('start_date', startDateInput.value);
|
|
||||||
params.append('end_date', endDateInput.value);
|
|
||||||
params.append('granularity', currentGranularity);
|
|
||||||
|
|
||||||
// Add multi-select params
|
|
||||||
selectedWorkcenterGroups.forEach(g => params.append('workcenter_groups', g));
|
|
||||||
selectedFamilies.forEach(f => params.append('families', f));
|
|
||||||
|
|
||||||
if (isProductionCheckbox.checked) params.append('is_production', '1');
|
|
||||||
if (isKeyCheckbox.checked) params.append('is_key', '1');
|
|
||||||
if (isMonitorCheckbox.checked) params.append('is_monitor', '1');
|
|
||||||
|
|
||||||
return params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeQuery() {
|
|
||||||
// Validate date range
|
|
||||||
const startDate = new Date(startDateInput.value);
|
|
||||||
const endDate = new Date(endDateInput.value);
|
|
||||||
const diffDays = (endDate - startDate) / (1000 * 60 * 60 * 24);
|
|
||||||
|
|
||||||
if (diffDays > 730) {
|
|
||||||
Toast.warning('查詢範圍不可超過兩年');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diffDays < 0) {
|
|
||||||
Toast.warning('結束日期必須大於起始日期');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showLoading();
|
|
||||||
queryBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const queryString = buildQueryString();
|
|
||||||
const summaryUrl = `/api/resource/history/summary?${queryString}`;
|
|
||||||
const detailUrl = `/api/resource/history/detail?${queryString}`;
|
|
||||||
|
|
||||||
// Fetch summary and detail in parallel using MesApi
|
|
||||||
const [summaryResult, detailResult] = await Promise.all([
|
|
||||||
MesApi.get(summaryUrl, { timeout: API_TIMEOUT }),
|
|
||||||
MesApi.get(detailUrl, { timeout: API_TIMEOUT })
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (summaryResult.success) {
|
|
||||||
const rawSummary = summaryResult.data || {};
|
|
||||||
const computedKpi = mergeComputedKpi(rawSummary.kpi || {});
|
|
||||||
const computedTrend = (rawSummary.trend || []).map((trendPoint) => mergeComputedKpi(trendPoint));
|
|
||||||
summaryData = {
|
|
||||||
...rawSummary,
|
|
||||||
kpi: computedKpi,
|
|
||||||
trend: computedTrend
|
|
||||||
};
|
|
||||||
|
|
||||||
updateKpiCards(summaryData.kpi);
|
|
||||||
updateTrendChart(summaryData.trend);
|
|
||||||
updateStackedChart(summaryData.trend);
|
|
||||||
updateComparisonChart(summaryData.workcenter_comparison);
|
|
||||||
updateHeatmapChart(summaryData.heatmap);
|
|
||||||
} else {
|
|
||||||
Toast.error(summaryResult.error || '查詢摘要失敗');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detailResult.success) {
|
|
||||||
detailData = detailResult.data;
|
|
||||||
hierarchyState = {};
|
|
||||||
renderDetailTable(detailData);
|
|
||||||
|
|
||||||
// Show warning if data was truncated
|
|
||||||
if (detailResult.truncated) {
|
|
||||||
Toast.warning(`明細資料超過 ${detailResult.max_records} 筆,僅顯示前 ${detailResult.max_records} 筆。請使用篩選條件縮小範圍。`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Toast.error(detailResult.error || '查詢明細失敗');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Query failed:', error);
|
|
||||||
Toast.error('查詢失敗: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
hideLoading();
|
|
||||||
queryBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// KPI Cards
|
|
||||||
// ============================================================
|
|
||||||
function mergeComputedKpi(kpi) {
|
|
||||||
return {
|
|
||||||
...kpi,
|
|
||||||
...buildResourceKpiFromHours(kpi)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateKpiCards(kpi) {
|
|
||||||
// OU% and AVAIL%
|
|
||||||
document.getElementById('kpiOuPct').textContent = kpi.ou_pct + '%';
|
|
||||||
document.getElementById('kpiAvailabilityPct').textContent = kpi.availability_pct + '%';
|
|
||||||
|
|
||||||
// PRD
|
|
||||||
document.getElementById('kpiPrdHours').textContent = formatHours(kpi.prd_hours);
|
|
||||||
document.getElementById('kpiPrdPct').textContent = `生產 (${kpi.prd_pct || 0}%)`;
|
|
||||||
|
|
||||||
// SBY
|
|
||||||
document.getElementById('kpiSbyHours').textContent = formatHours(kpi.sby_hours);
|
|
||||||
document.getElementById('kpiSbyPct').textContent = `待機 (${kpi.sby_pct || 0}%)`;
|
|
||||||
|
|
||||||
// UDT
|
|
||||||
document.getElementById('kpiUdtHours').textContent = formatHours(kpi.udt_hours);
|
|
||||||
document.getElementById('kpiUdtPct').textContent = `非計畫停機 (${kpi.udt_pct || 0}%)`;
|
|
||||||
|
|
||||||
// SDT
|
|
||||||
document.getElementById('kpiSdtHours').textContent = formatHours(kpi.sdt_hours);
|
|
||||||
document.getElementById('kpiSdtPct').textContent = `計畫停機 (${kpi.sdt_pct || 0}%)`;
|
|
||||||
|
|
||||||
// EGT
|
|
||||||
document.getElementById('kpiEgtHours').textContent = formatHours(kpi.egt_hours);
|
|
||||||
document.getElementById('kpiEgtPct').textContent = `工程 (${kpi.egt_pct || 0}%)`;
|
|
||||||
|
|
||||||
// NST
|
|
||||||
document.getElementById('kpiNstHours').textContent = formatHours(kpi.nst_hours);
|
|
||||||
document.getElementById('kpiNstPct').textContent = `未排程 (${kpi.nst_pct || 0}%)`;
|
|
||||||
|
|
||||||
// Machine count
|
|
||||||
const machineCount = Number(kpi.machine_count || 0);
|
|
||||||
document.getElementById('kpiMachineCount').textContent = machineCount.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHours(hours) {
|
|
||||||
if (hours >= 1000) {
|
|
||||||
return (hours / 1000).toFixed(1) + 'K';
|
|
||||||
}
|
|
||||||
return hours.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Charts
|
|
||||||
// ============================================================
|
|
||||||
function updateTrendChart(trend) {
|
|
||||||
const dates = trend.map(t => t.date);
|
|
||||||
const ouPcts = trend.map(t => t.ou_pct);
|
|
||||||
const availabilityPcts = trend.map(t => t.availability_pct);
|
|
||||||
|
|
||||||
charts.trend.setOption({
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
formatter: function(params) {
|
|
||||||
const d = trend[params[0].dataIndex];
|
|
||||||
return `${d.date}<br/>
|
|
||||||
<span style="color:#3B82F6">●</span> OU%: <b>${d.ou_pct}%</b><br/>
|
|
||||||
<span style="color:#10B981">●</span> AVAIL%: <b>${d.availability_pct}%</b><br/>
|
|
||||||
PRD: ${d.prd_hours}h<br/>
|
|
||||||
SBY: ${d.sby_hours}h<br/>
|
|
||||||
UDT: ${d.udt_hours}h`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
data: ['OU%', 'AVAIL%'],
|
|
||||||
bottom: 0,
|
|
||||||
textStyle: { fontSize: 11 }
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: dates,
|
|
||||||
axisLabel: { fontSize: 11 }
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
name: '%',
|
|
||||||
max: 100,
|
|
||||||
axisLabel: { formatter: '{value}%' }
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'OU%',
|
|
||||||
data: ouPcts,
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
areaStyle: { opacity: 0.2 },
|
|
||||||
itemStyle: { color: '#3B82F6' },
|
|
||||||
lineStyle: { width: 2 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AVAIL%',
|
|
||||||
data: availabilityPcts,
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
areaStyle: { opacity: 0.2 },
|
|
||||||
itemStyle: { color: '#10B981' },
|
|
||||||
lineStyle: { width: 2 }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
grid: { left: 50, right: 20, top: 30, bottom: 50 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStackedChart(trend) {
|
|
||||||
const dates = trend.map(t => t.date);
|
|
||||||
|
|
||||||
charts.stacked.setOption({
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: { type: 'shadow' },
|
|
||||||
formatter: function(params) {
|
|
||||||
const idx = params[0].dataIndex;
|
|
||||||
const d = trend[idx];
|
|
||||||
const total = d.prd_hours + d.sby_hours + d.udt_hours + d.sdt_hours + d.egt_hours + d.nst_hours;
|
|
||||||
const pct = (v) => total > 0 ? (v / total * 100).toFixed(1) : 0;
|
|
||||||
return `<b>${d.date}</b><br/>
|
|
||||||
<span style="color:#22c55e">●</span> PRD: ${d.prd_hours}h (${pct(d.prd_hours)}%)<br/>
|
|
||||||
<span style="color:#3b82f6">●</span> SBY: ${d.sby_hours}h (${pct(d.sby_hours)}%)<br/>
|
|
||||||
<span style="color:#ef4444">●</span> UDT: ${d.udt_hours}h (${pct(d.udt_hours)}%)<br/>
|
|
||||||
<span style="color:#f59e0b">●</span> SDT: ${d.sdt_hours}h (${pct(d.sdt_hours)}%)<br/>
|
|
||||||
<span style="color:#8b5cf6">●</span> EGT: ${d.egt_hours}h (${pct(d.egt_hours)}%)<br/>
|
|
||||||
<span style="color:#64748b">●</span> NST: ${d.nst_hours}h (${pct(d.nst_hours)}%)<br/>
|
|
||||||
<b>Total: ${total.toFixed(1)}h</b>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
data: ['PRD', 'SBY', 'UDT', 'SDT', 'EGT', 'NST'],
|
|
||||||
bottom: 0,
|
|
||||||
textStyle: { fontSize: 10 }
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: dates,
|
|
||||||
axisLabel: { fontSize: 10 }
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
name: '時數',
|
|
||||||
axisLabel: { formatter: '{value}h' }
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{ name: 'PRD', type: 'bar', stack: 'total', data: trend.map(t => t.prd_hours), itemStyle: { color: '#22c55e' } },
|
|
||||||
{ name: 'SBY', type: 'bar', stack: 'total', data: trend.map(t => t.sby_hours), itemStyle: { color: '#3b82f6' } },
|
|
||||||
{ name: 'UDT', type: 'bar', stack: 'total', data: trend.map(t => t.udt_hours), itemStyle: { color: '#ef4444' } },
|
|
||||||
{ name: 'SDT', type: 'bar', stack: 'total', data: trend.map(t => t.sdt_hours), itemStyle: { color: '#f59e0b' } },
|
|
||||||
{ name: 'EGT', type: 'bar', stack: 'total', data: trend.map(t => t.egt_hours), itemStyle: { color: '#8b5cf6' } },
|
|
||||||
{ name: 'NST', type: 'bar', stack: 'total', data: trend.map(t => t.nst_hours), itemStyle: { color: '#64748b' } }
|
|
||||||
],
|
|
||||||
grid: { left: 50, right: 20, top: 30, bottom: 60 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateComparisonChart(comparison) {
|
|
||||||
// Take top 15 workcenters and reverse for bottom-to-top display (highest at top)
|
|
||||||
const data = comparison.slice(0, 15).reverse();
|
|
||||||
const workcenters = data.map(d => d.workcenter);
|
|
||||||
const ouPcts = data.map(d => d.ou_pct);
|
|
||||||
|
|
||||||
charts.comparison.setOption({
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: { type: 'shadow' },
|
|
||||||
formatter: function(params) {
|
|
||||||
const d = data[params[0].dataIndex];
|
|
||||||
return `${d.workcenter}<br/>OU%: <b>${d.ou_pct}%</b><br/>機台數: ${d.machine_count}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'value',
|
|
||||||
name: 'OU%',
|
|
||||||
max: 100,
|
|
||||||
axisLabel: { formatter: '{value}%' }
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: workcenters,
|
|
||||||
axisLabel: { fontSize: 10 }
|
|
||||||
},
|
|
||||||
series: [{
|
|
||||||
type: 'bar',
|
|
||||||
data: ouPcts,
|
|
||||||
itemStyle: {
|
|
||||||
color: function(params) {
|
|
||||||
const val = params.value;
|
|
||||||
if (val >= 80) return '#22c55e';
|
|
||||||
if (val >= 50) return '#f59e0b';
|
|
||||||
return '#ef4444';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
grid: { left: 100, right: 30, top: 20, bottom: 30 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHeatmapChart(heatmap) {
|
|
||||||
if (!heatmap || heatmap.length === 0) {
|
|
||||||
charts.heatmap.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build workcenter list with sequence for sorting
|
|
||||||
const wcSeqMap = {};
|
|
||||||
heatmap.forEach(h => {
|
|
||||||
wcSeqMap[h.workcenter] = h.workcenter_seq ?? 999;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get unique workcenters sorted by sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
|
|
||||||
const workcenters = [...new Set(heatmap.map(h => h.workcenter))]
|
|
||||||
.sort((a, b) => wcSeqMap[a] - wcSeqMap[b]);
|
|
||||||
const dates = [...new Set(heatmap.map(h => h.date))].sort();
|
|
||||||
|
|
||||||
// Build data matrix
|
|
||||||
const data = heatmap.map(h => [
|
|
||||||
dates.indexOf(h.date),
|
|
||||||
workcenters.indexOf(h.workcenter),
|
|
||||||
h.ou_pct
|
|
||||||
]);
|
|
||||||
|
|
||||||
charts.heatmap.setOption({
|
|
||||||
tooltip: {
|
|
||||||
position: 'top',
|
|
||||||
formatter: function(params) {
|
|
||||||
return `${workcenters[params.value[1]]}<br/>${dates[params.value[0]]}<br/>OU%: <b>${params.value[2]}%</b>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: dates,
|
|
||||||
splitArea: { show: true },
|
|
||||||
axisLabel: { fontSize: 9, rotate: 45 }
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: workcenters,
|
|
||||||
splitArea: { show: true },
|
|
||||||
axisLabel: { fontSize: 9 }
|
|
||||||
},
|
|
||||||
visualMap: {
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
calculable: true,
|
|
||||||
orient: 'horizontal',
|
|
||||||
left: 'center',
|
|
||||||
bottom: 0,
|
|
||||||
inRange: {
|
|
||||||
color: ['#ef4444', '#f59e0b', '#22c55e']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
series: [{
|
|
||||||
type: 'heatmap',
|
|
||||||
data: data,
|
|
||||||
label: { show: false },
|
|
||||||
emphasis: {
|
|
||||||
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
grid: { left: 100, right: 20, top: 10, bottom: 60 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Hierarchical Table
|
|
||||||
// ============================================================
|
|
||||||
function renderDetailTable(data) {
|
|
||||||
const tbody = document.getElementById('detailTableBody');
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
tbody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="10">
|
|
||||||
<div class="placeholder">
|
|
||||||
<div class="placeholder-icon">🔍</div>
|
|
||||||
<div class="placeholder-text">無符合條件的資料</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build hierarchy
|
|
||||||
const hierarchy = buildHierarchy(data);
|
|
||||||
|
|
||||||
// Render rows
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
hierarchy.forEach(wc => {
|
|
||||||
// Workcenter level
|
|
||||||
const wcRow = createRow(wc, 0, `wc_${wc.workcenter}`);
|
|
||||||
tbody.appendChild(wcRow);
|
|
||||||
|
|
||||||
// Family level
|
|
||||||
if (hierarchyState[`wc_${wc.workcenter}`]) {
|
|
||||||
wc.families.forEach(fam => {
|
|
||||||
const famRow = createRow(fam, 1, `fam_${wc.workcenter}_${fam.family}`);
|
|
||||||
famRow.dataset.parent = `wc_${wc.workcenter}`;
|
|
||||||
tbody.appendChild(famRow);
|
|
||||||
|
|
||||||
// Resource level
|
|
||||||
if (hierarchyState[`fam_${wc.workcenter}_${fam.family}`]) {
|
|
||||||
fam.resources.forEach(res => {
|
|
||||||
const resRow = createRow(res, 2);
|
|
||||||
resRow.dataset.parent = `fam_${wc.workcenter}_${fam.family}`;
|
|
||||||
tbody.appendChild(resRow);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHierarchy(data) {
|
|
||||||
const wcMap = {};
|
|
||||||
|
|
||||||
data.forEach(item => {
|
|
||||||
const wc = item.workcenter;
|
|
||||||
const fam = item.family;
|
|
||||||
const wcSeq = item.workcenter_seq ?? 999;
|
|
||||||
|
|
||||||
if (!wcMap[wc]) {
|
|
||||||
wcMap[wc] = {
|
|
||||||
workcenter: wc,
|
|
||||||
name: wc,
|
|
||||||
sequence: wcSeq,
|
|
||||||
families: [],
|
|
||||||
familyMap: {},
|
|
||||||
ou_pct: 0, availability_pct: 0, prd_hours: 0, prd_pct: 0,
|
|
||||||
sby_hours: 0, sby_pct: 0, udt_hours: 0, udt_pct: 0,
|
|
||||||
sdt_hours: 0, sdt_pct: 0, egt_hours: 0, egt_pct: 0,
|
|
||||||
nst_hours: 0, nst_pct: 0, machine_count: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wcMap[wc].familyMap[fam]) {
|
|
||||||
wcMap[wc].familyMap[fam] = {
|
|
||||||
family: fam,
|
|
||||||
name: fam,
|
|
||||||
resources: [],
|
|
||||||
ou_pct: 0, availability_pct: 0, prd_hours: 0, prd_pct: 0,
|
|
||||||
sby_hours: 0, sby_pct: 0, udt_hours: 0, udt_pct: 0,
|
|
||||||
sdt_hours: 0, sdt_pct: 0, egt_hours: 0, egt_pct: 0,
|
|
||||||
nst_hours: 0, nst_pct: 0, machine_count: 0
|
|
||||||
};
|
|
||||||
wcMap[wc].families.push(wcMap[wc].familyMap[fam]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add resource
|
|
||||||
wcMap[wc].familyMap[fam].resources.push({
|
|
||||||
name: item.resource,
|
|
||||||
...item
|
|
||||||
});
|
|
||||||
|
|
||||||
// Aggregate to family
|
|
||||||
const famObj = wcMap[wc].familyMap[fam];
|
|
||||||
famObj.prd_hours += item.prd_hours;
|
|
||||||
famObj.sby_hours += item.sby_hours;
|
|
||||||
famObj.udt_hours += item.udt_hours;
|
|
||||||
famObj.sdt_hours += item.sdt_hours;
|
|
||||||
famObj.egt_hours += item.egt_hours;
|
|
||||||
famObj.nst_hours += item.nst_hours;
|
|
||||||
famObj.machine_count += 1;
|
|
||||||
|
|
||||||
// Aggregate to workcenter
|
|
||||||
wcMap[wc].prd_hours += item.prd_hours;
|
|
||||||
wcMap[wc].sby_hours += item.sby_hours;
|
|
||||||
wcMap[wc].udt_hours += item.udt_hours;
|
|
||||||
wcMap[wc].sdt_hours += item.sdt_hours;
|
|
||||||
wcMap[wc].egt_hours += item.egt_hours;
|
|
||||||
wcMap[wc].nst_hours += item.nst_hours;
|
|
||||||
wcMap[wc].machine_count += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate OU% and percentages
|
|
||||||
Object.values(wcMap).forEach(wc => {
|
|
||||||
calcPercentages(wc);
|
|
||||||
wc.families.forEach(fam => {
|
|
||||||
calcPercentages(fam);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by workcenter sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
|
|
||||||
return Object.values(wcMap).sort((a, b) => a.sequence - b.sequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcPercentages(obj) {
|
|
||||||
Object.assign(obj, buildResourceKpiFromHours(obj));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRow(item, level, rowId) {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.className = `row-level-${level}`;
|
|
||||||
if (rowId) tr.dataset.rowId = rowId;
|
|
||||||
|
|
||||||
const indentClass = level > 0 ? `indent-${level}` : '';
|
|
||||||
const hasChildren = level < 2 && (item.families?.length > 0 || item.resources?.length > 0);
|
|
||||||
const isExpanded = rowId ? hierarchyState[rowId] : false;
|
|
||||||
|
|
||||||
const expandBtn = hasChildren
|
|
||||||
? `<button class="expand-btn ${isExpanded ? 'expanded' : ''}" onclick="toggleRow('${rowId}')">▶</button>`
|
|
||||||
: '<span style="display:inline-block;width:24px;"></span>';
|
|
||||||
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td class="${indentClass}">${expandBtn}${item.name}</td>
|
|
||||||
<td><b>${item.ou_pct}%</b></td>
|
|
||||||
<td><b>${item.availability_pct}%</b></td>
|
|
||||||
<td class="status-prd">${formatHoursPct(item.prd_hours, item.prd_pct)}</td>
|
|
||||||
<td class="status-sby">${formatHoursPct(item.sby_hours, item.sby_pct)}</td>
|
|
||||||
<td class="status-udt">${formatHoursPct(item.udt_hours, item.udt_pct)}</td>
|
|
||||||
<td class="status-sdt">${formatHoursPct(item.sdt_hours, item.sdt_pct)}</td>
|
|
||||||
<td class="status-egt">${formatHoursPct(item.egt_hours, item.egt_pct)}</td>
|
|
||||||
<td class="status-nst">${formatHoursPct(item.nst_hours, item.nst_pct)}</td>
|
|
||||||
<td>${item.machine_count}</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return tr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHoursPct(hours, pct) {
|
|
||||||
return `${Math.round(hours * 10) / 10}h (${pct}%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make toggleRow global
|
|
||||||
window.toggleRow = function(rowId) {
|
|
||||||
hierarchyState[rowId] = !hierarchyState[rowId];
|
|
||||||
renderDetailTable(detailData);
|
|
||||||
};
|
|
||||||
|
|
||||||
function toggleAllRows(expand) {
|
|
||||||
if (!detailData) return;
|
|
||||||
|
|
||||||
const hierarchy = buildHierarchy(detailData);
|
|
||||||
hierarchy.forEach(wc => {
|
|
||||||
hierarchyState[`wc_${wc.workcenter}`] = expand;
|
|
||||||
wc.families.forEach(fam => {
|
|
||||||
hierarchyState[`fam_${wc.workcenter}_${fam.family}`] = expand;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
renderDetailTable(detailData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Export
|
|
||||||
// ============================================================
|
|
||||||
function exportCsv() {
|
|
||||||
if (!startDateInput.value || !endDateInput.value) {
|
|
||||||
Toast.warning('請先設定查詢條件');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = buildQueryString();
|
|
||||||
const url = `/api/resource/history/export?${queryString}`;
|
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `resource_history_${startDateInput.value}_to_${endDateInput.value}.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
|
|
||||||
Toast.success('CSV 匯出中...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Loading
|
|
||||||
// ============================================================
|
|
||||||
function showLoading() {
|
|
||||||
loadingOverlay.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideLoading() {
|
|
||||||
loadingOverlay.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(window, {
|
|
||||||
init,
|
|
||||||
setDefaultDates,
|
|
||||||
formatDate,
|
|
||||||
setupEventListeners,
|
|
||||||
initCharts,
|
|
||||||
loadFilterOptions,
|
|
||||||
populateMultiSelect,
|
|
||||||
setupMultiSelectDropdowns,
|
|
||||||
updateSelectedWorkcenterGroups,
|
|
||||||
updateSelectedFamilies,
|
|
||||||
updateMultiSelectText,
|
|
||||||
buildQueryString,
|
|
||||||
executeQuery,
|
|
||||||
updateKpiCards,
|
|
||||||
formatHours,
|
|
||||||
updateTrendChart,
|
|
||||||
updateStackedChart,
|
|
||||||
updateComparisonChart,
|
|
||||||
updateHeatmapChart,
|
|
||||||
renderDetailTable,
|
|
||||||
buildHierarchy,
|
|
||||||
calcPercentages,
|
|
||||||
createRow,
|
|
||||||
formatHoursPct,
|
|
||||||
toggleAllRows,
|
|
||||||
exportCsv,
|
|
||||||
showLoading,
|
|
||||||
hideLoading,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Start
|
|
||||||
// ============================================================
|
|
||||||
init();
|
|
||||||
})();
|
|
||||||
|
|||||||
238
frontend/src/resource-history/style.css
Normal file
238
frontend/src/resource-history/style.css
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
.history-header {
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #0ea5e9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field input[type='date'] {
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.granularity-btns {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.granularity-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.granularity-btn.active {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select {
|
||||||
|
position: relative;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-arrow {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-options {
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option input[type='checkbox'] {
|
||||||
|
margin: 0;
|
||||||
|
accent-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid var(--resource-border);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-pill input[type='checkbox'] {
|
||||||
|
margin: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: var(--resource-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 12px 12px 10px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-body {
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-no-data {
|
||||||
|
height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-error {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.chart-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.multi-select {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
331
frontend/src/resource-shared/components/HierarchyTable.vue
Normal file
331
frontend/src/resource-shared/components/HierarchyTable.vue
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
hierarchy: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
expandedState: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
nameColumnLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '名稱',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: '無資料',
|
||||||
|
},
|
||||||
|
showToolbar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['cell-click', 'toggle-row', 'toggle-all']);
|
||||||
|
|
||||||
|
const hasRows = computed(() => Array.isArray(props.hierarchy) && props.hierarchy.length > 0);
|
||||||
|
|
||||||
|
function getNodeChildren(node) {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
return node.children;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.families)) {
|
||||||
|
return node.families;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.resources)) {
|
||||||
|
return node.resources;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.equipment)) {
|
||||||
|
return node.equipment;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeId(node, parentId, index, level) {
|
||||||
|
if (node?.id) {
|
||||||
|
return String(node.id);
|
||||||
|
}
|
||||||
|
if (node?.rowId) {
|
||||||
|
return String(node.rowId);
|
||||||
|
}
|
||||||
|
return `${parentId || 'row'}_${level}_${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChildren(node) {
|
||||||
|
return getNodeChildren(node).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(node, rowId) {
|
||||||
|
if (!hasChildren(node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(props.expandedState?.[rowId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeLabel(node) {
|
||||||
|
return node?.name || node?.label || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndentClass(level) {
|
||||||
|
if (level === 1) {
|
||||||
|
return 'indent-1';
|
||||||
|
}
|
||||||
|
if (level === 2) {
|
||||||
|
return 'indent-2';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowClasses(node, level) {
|
||||||
|
const classes = [`row-level-${level}`];
|
||||||
|
const indentClass = getIndentClass(level);
|
||||||
|
if (indentClass) {
|
||||||
|
classes.push(indentClass);
|
||||||
|
}
|
||||||
|
if (node?.rowClass) {
|
||||||
|
classes.push(node.rowClass);
|
||||||
|
}
|
||||||
|
if (node?.rowClickable) {
|
||||||
|
classes.push('clickable-row');
|
||||||
|
}
|
||||||
|
if (node?.rowSelected) {
|
||||||
|
classes.push('selected');
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCellValue(node, column) {
|
||||||
|
if (typeof column?.value === 'function') {
|
||||||
|
return column.value(node);
|
||||||
|
}
|
||||||
|
if (node?.values && Object.prototype.hasOwnProperty.call(node.values, column.key)) {
|
||||||
|
return node.values[column.key];
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(node || {}, column.key)) {
|
||||||
|
return node[column.key];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellDisplay(node, column) {
|
||||||
|
const rendered = typeof column?.render === 'function' ? column.render(node) : resolveCellValue(node, column);
|
||||||
|
if (rendered && typeof rendered === 'object' && !Array.isArray(rendered)) {
|
||||||
|
return {
|
||||||
|
text: rendered.text ?? rendered.value ?? '',
|
||||||
|
badgeClass: rendered.badgeClass || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: rendered ?? '',
|
||||||
|
badgeClass: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCellClickable(node, column) {
|
||||||
|
if (typeof column?.isClickable === 'function') {
|
||||||
|
return Boolean(column.isClickable(node));
|
||||||
|
}
|
||||||
|
return Boolean(column?.clickable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCellSelected(node, column) {
|
||||||
|
if (typeof column?.isSelected === 'function') {
|
||||||
|
return Boolean(column.isSelected(node));
|
||||||
|
}
|
||||||
|
return Boolean(node?.selectedColumns && node.selectedColumns[column?.key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellClasses(node, column, display) {
|
||||||
|
const classes = [];
|
||||||
|
if (column?.className) {
|
||||||
|
classes.push(column.className);
|
||||||
|
}
|
||||||
|
if (typeof column?.cellClass === 'function') {
|
||||||
|
const dynamicClass = column.cellClass(node, display);
|
||||||
|
if (dynamicClass) {
|
||||||
|
classes.push(dynamicClass);
|
||||||
|
}
|
||||||
|
} else if (column?.cellClass) {
|
||||||
|
classes.push(column.cellClass);
|
||||||
|
}
|
||||||
|
if (isCellClickable(node, column)) {
|
||||||
|
classes.push('clickable');
|
||||||
|
}
|
||||||
|
if (isCellSelected(node, column)) {
|
||||||
|
classes.push('selected');
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCellClick(node, column) {
|
||||||
|
if (!isCellClickable(node, column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = typeof column?.payload === 'function' ? column.payload(node) : null;
|
||||||
|
emit('cell-click', { node, column, payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowClick(node) {
|
||||||
|
if (!node?.rowClickable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('cell-click', {
|
||||||
|
node,
|
||||||
|
column: null,
|
||||||
|
payload: node.rowPayload || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleRow(rowId) {
|
||||||
|
emit('toggle-row', rowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleAll(expand) {
|
||||||
|
emit('toggle-all', expand);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="hierarchy-table-wrap">
|
||||||
|
<div v-if="showToolbar" class="table-tree-actions">
|
||||||
|
<button type="button" class="btn-sm" @click="handleToggleAll(true)">全部展開</button>
|
||||||
|
<button type="button" class="btn-sm" @click="handleToggleAll(false)">全部收合</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="matrix-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ nameColumnLabel }}</th>
|
||||||
|
<th v-for="column in columns" :key="column.key" :class="column.headerClass || ''">
|
||||||
|
{{ column.label }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="hasRows">
|
||||||
|
<template v-for="(group, groupIndex) in hierarchy" :key="getNodeId(group, 'root', groupIndex, 0)">
|
||||||
|
<tr :class="getRowClasses(group, 0)" @click="handleRowClick(group)">
|
||||||
|
<td>
|
||||||
|
<span class="row-name">
|
||||||
|
<button
|
||||||
|
v-if="hasChildren(group)"
|
||||||
|
type="button"
|
||||||
|
class="expand-btn"
|
||||||
|
:class="{ expanded: isExpanded(group, getNodeId(group, 'root', groupIndex, 0)) }"
|
||||||
|
@click.stop="handleToggleRow(getNodeId(group, 'root', groupIndex, 0))"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<span v-else class="expand-placeholder"></span>
|
||||||
|
<span>{{ getNodeLabel(group) }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="`g-${getNodeId(group, 'root', groupIndex, 0)}-${column.key}`"
|
||||||
|
:class="getCellClasses(group, column, getCellDisplay(group, column))"
|
||||||
|
@click.stop="handleCellClick(group, column)"
|
||||||
|
>
|
||||||
|
<template v-for="cell in [getCellDisplay(group, column)]" :key="`gc-${column.key}`">
|
||||||
|
<span v-if="cell.badgeClass" :class="cell.badgeClass">{{ cell.text }}</span>
|
||||||
|
<template v-else>{{ cell.text }}</template>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<template v-if="isExpanded(group, getNodeId(group, 'root', groupIndex, 0))">
|
||||||
|
<template
|
||||||
|
v-for="(family, familyIndex) in getNodeChildren(group)"
|
||||||
|
:key="getNodeId(family, getNodeId(group, 'root', groupIndex, 0), familyIndex, 1)"
|
||||||
|
>
|
||||||
|
<tr :class="getRowClasses(family, 1)" @click="handleRowClick(family)">
|
||||||
|
<td>
|
||||||
|
<span class="row-name">
|
||||||
|
<button
|
||||||
|
v-if="hasChildren(family)"
|
||||||
|
type="button"
|
||||||
|
class="expand-btn"
|
||||||
|
:class="{
|
||||||
|
expanded: isExpanded(
|
||||||
|
family,
|
||||||
|
getNodeId(family, getNodeId(group, 'root', groupIndex, 0), familyIndex, 1)
|
||||||
|
),
|
||||||
|
}"
|
||||||
|
@click.stop="handleToggleRow(
|
||||||
|
getNodeId(family, getNodeId(group, 'root', groupIndex, 0), familyIndex, 1)
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<span v-else class="expand-placeholder"></span>
|
||||||
|
<span>{{ getNodeLabel(family) }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="`f-${getNodeId(family, getNodeId(group, 'root', groupIndex, 0), familyIndex, 1)}-${column.key}`"
|
||||||
|
:class="getCellClasses(family, column, getCellDisplay(family, column))"
|
||||||
|
@click.stop="handleCellClick(family, column)"
|
||||||
|
>
|
||||||
|
<template v-for="cell in [getCellDisplay(family, column)]" :key="`fc-${column.key}`">
|
||||||
|
<span v-if="cell.badgeClass" :class="cell.badgeClass">{{ cell.text }}</span>
|
||||||
|
<template v-else>{{ cell.text }}</template>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-if="isExpanded(
|
||||||
|
family,
|
||||||
|
getNodeId(family, getNodeId(group, 'root', groupIndex, 0), familyIndex, 1)
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<tr
|
||||||
|
v-for="(resource, resourceIndex) in getNodeChildren(family)"
|
||||||
|
:key="getNodeId(resource, getNodeId(family, getNodeId(group, 'root', groupIndex, 0), familyIndex, 1), resourceIndex, 2)"
|
||||||
|
:class="getRowClasses(resource, 2)"
|
||||||
|
@click="handleRowClick(resource)"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span class="row-name">
|
||||||
|
<span class="expand-placeholder"></span>
|
||||||
|
<span>{{ getNodeLabel(resource) }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="`r-${getNodeId(resource, getNodeId(family, getNodeId(group, 'root', groupIndex, 0), familyIndex, 1), resourceIndex, 2)}-${column.key}`"
|
||||||
|
:class="getCellClasses(resource, column, getCellDisplay(resource, column))"
|
||||||
|
@click.stop="handleCellClick(resource, column)"
|
||||||
|
>
|
||||||
|
<template v-for="cell in [getCellDisplay(resource, column)]" :key="`rc-${column.key}`">
|
||||||
|
<span v-if="cell.badgeClass" :class="cell.badgeClass">{{ cell.text }}</span>
|
||||||
|
<template v-else>{{ cell.text }}</template>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<tr v-else>
|
||||||
|
<td :colspan="columns.length + 1">
|
||||||
|
<div class="empty-state">{{ emptyText }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
70
frontend/src/resource-shared/constants.js
Normal file
70
frontend/src/resource-shared/constants.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export const STATUS_DISPLAY_MAP = Object.freeze({
|
||||||
|
PRD: '生產中',
|
||||||
|
SBY: '待機',
|
||||||
|
UDT: '非計畫停機',
|
||||||
|
SDT: '計畫停機',
|
||||||
|
EGT: '工程',
|
||||||
|
NST: '未排程',
|
||||||
|
PM: '保養',
|
||||||
|
BKD: '故障',
|
||||||
|
ENG: '工程',
|
||||||
|
OFF: '關機',
|
||||||
|
OTHER: '其他',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const STATUS_AGGREGATION = Object.freeze({
|
||||||
|
PM: 'UDT',
|
||||||
|
BKD: 'UDT',
|
||||||
|
ENG: 'EGT',
|
||||||
|
OFF: 'NST',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const STATUS_COLORS = Object.freeze({
|
||||||
|
PRD: '#22c55e',
|
||||||
|
SBY: '#3b82f6',
|
||||||
|
UDT: '#ef4444',
|
||||||
|
SDT: '#f59e0b',
|
||||||
|
EGT: '#8b5cf6',
|
||||||
|
NST: '#64748b',
|
||||||
|
OTHER: '#94a3b8',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OU_BADGE_THRESHOLDS = Object.freeze({
|
||||||
|
high: 80,
|
||||||
|
medium: 50,
|
||||||
|
low: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MATRIX_STATUS_COLUMNS = Object.freeze(['PRD', 'SBY', 'UDT', 'SDT', 'EGT', 'NST', 'OTHER']);
|
||||||
|
|
||||||
|
export function normalizeStatus(rawStatus) {
|
||||||
|
const status = String(rawStatus || '').trim().toUpperCase();
|
||||||
|
if (!status) {
|
||||||
|
return 'OTHER';
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregated = STATUS_AGGREGATION[status] || status;
|
||||||
|
if (MATRIX_STATUS_COLUMNS.includes(aggregated)) {
|
||||||
|
return aggregated;
|
||||||
|
}
|
||||||
|
return 'OTHER';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOuBadgeClass(ouPct) {
|
||||||
|
const value = Number(ouPct || 0);
|
||||||
|
if (value >= OU_BADGE_THRESHOLDS.high) {
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
if (value >= OU_BADGE_THRESHOLDS.medium) {
|
||||||
|
return 'medium';
|
||||||
|
}
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusDisplay(status, fallback = '--') {
|
||||||
|
const normalized = String(status || '').trim().toUpperCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return STATUS_DISPLAY_MAP[normalized] || normalized;
|
||||||
|
}
|
||||||
561
frontend/src/resource-shared/styles.css
Normal file
561
frontend/src/resource-shared/styles.css
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
:root {
|
||||||
|
--resource-bg: #f5f7fb;
|
||||||
|
--resource-card-bg: #ffffff;
|
||||||
|
--resource-text: #1f2937;
|
||||||
|
--resource-muted: #64748b;
|
||||||
|
--resource-border: #dbe4ef;
|
||||||
|
--resource-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
--resource-shadow-md: 0 8px 22px rgba(15, 23, 42, 0.1);
|
||||||
|
--resource-primary: #2563eb;
|
||||||
|
--resource-primary-dark: #1d4ed8;
|
||||||
|
|
||||||
|
--status-prd: #22c55e;
|
||||||
|
--status-sby: #3b82f6;
|
||||||
|
--status-udt: #ef4444;
|
||||||
|
--status-sdt: #f59e0b;
|
||||||
|
--status-egt: #8b5cf6;
|
||||||
|
--status-nst: #64748b;
|
||||||
|
--status-other: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Microsoft JhengHei', 'PingFang TC', 'Noto Sans TC', sans-serif;
|
||||||
|
background: var(--resource-bg);
|
||||||
|
color: var(--resource-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
max-width: 1800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-gradient {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
||||||
|
box-shadow: var(--resource-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-gradient h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background: var(--resource-card-bg);
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--resource-shadow);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-inner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.btn-sm {
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--resource-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover,
|
||||||
|
.btn-sm:hover {
|
||||||
|
border-color: #c2d0e0;
|
||||||
|
background: #eef4fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled,
|
||||||
|
.btn-sm:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--resource-primary);
|
||||||
|
border-color: var(--resource-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--resource-primary-dark);
|
||||||
|
border-color: var(--resource-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(10, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
background: var(--resource-card-bg);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--resource-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--resource-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.clickable:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 7px 16px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.active {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--resource-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--resource-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.prd::before { background: var(--status-prd); }
|
||||||
|
.summary-card.prd .summary-value { color: var(--status-prd); }
|
||||||
|
|
||||||
|
.summary-card.sby::before { background: var(--status-sby); }
|
||||||
|
.summary-card.sby .summary-value { color: var(--status-sby); }
|
||||||
|
|
||||||
|
.summary-card.udt::before { background: var(--status-udt); }
|
||||||
|
.summary-card.udt .summary-value { color: var(--status-udt); }
|
||||||
|
|
||||||
|
.summary-card.sdt::before { background: var(--status-sdt); }
|
||||||
|
.summary-card.sdt .summary-value { color: var(--status-sdt); }
|
||||||
|
|
||||||
|
.summary-card.egt::before { background: var(--status-egt); }
|
||||||
|
.summary-card.egt .summary-value { color: var(--status-egt); }
|
||||||
|
|
||||||
|
.summary-card.nst::before { background: var(--status-nst); }
|
||||||
|
.summary-card.nst .summary-value { color: var(--status-nst); }
|
||||||
|
|
||||||
|
.summary-card.other::before { background: var(--status-other); }
|
||||||
|
.summary-card.other .summary-value { color: var(--status-other); }
|
||||||
|
|
||||||
|
.summary-card.ou::before { background: var(--resource-primary); }
|
||||||
|
.summary-card.availability::before { background: #22c55e; }
|
||||||
|
.summary-card.total::before { background: var(--resource-muted); }
|
||||||
|
|
||||||
|
.filter-indicator {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-indicator.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-indicator .filter-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hierarchy-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-tree-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table th,
|
||||||
|
.matrix-table td {
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
padding: 8px 6px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table th:first-child,
|
||||||
|
.matrix-table td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table tbody tr:hover {
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .col-total {
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .col-prd {
|
||||||
|
color: var(--status-prd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .col-sby {
|
||||||
|
color: var(--status-sby);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .col-udt {
|
||||||
|
color: var(--status-udt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .col-sdt {
|
||||||
|
color: var(--status-sdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .col-egt {
|
||||||
|
color: var(--status-egt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .col-nst {
|
||||||
|
color: var(--status-nst);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .col-other {
|
||||||
|
color: var(--status-other);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table .zero {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table td.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table td.clickable:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table td.clickable.selected,
|
||||||
|
.matrix-table tr.clickable-row.selected {
|
||||||
|
background: #dbeafe;
|
||||||
|
box-shadow: inset 0 0 0 2px #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table tr.clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-level-0 {
|
||||||
|
background: #f8fafc;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-level-1 {
|
||||||
|
background: #f9fbfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-level-2 {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-placeholder {
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indent-1 td:first-child {
|
||||||
|
padding-left: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indent-2 td:first-child {
|
||||||
|
padding-left: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ou-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 58px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ou-badge.high {
|
||||||
|
color: #166534;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ou-badge.medium {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ou-badge.low {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card .ou-badge {
|
||||||
|
font-size: 24px;
|
||||||
|
min-width: auto;
|
||||||
|
padding: 4px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-status.prd,
|
||||||
|
.eq-status.productive {
|
||||||
|
background: var(--status-prd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-status.sby,
|
||||||
|
.eq-status.standby {
|
||||||
|
background: var(--status-sby);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-status.udt,
|
||||||
|
.eq-status.down {
|
||||||
|
background: var(--status-udt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-status.sdt {
|
||||||
|
background: var(--status-sdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-status.egt,
|
||||||
|
.eq-status.engineering {
|
||||||
|
background: var(--status-egt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-status.nst,
|
||||||
|
.eq-status.not_scheduled {
|
||||||
|
background: var(--status-nst);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-status.other,
|
||||||
|
.eq-status.unknown,
|
||||||
|
.eq-status.inactive {
|
||||||
|
background: var(--status-other);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 36px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--resource-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(15, 23, 42, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 4px solid #e2e8f0;
|
||||||
|
border-top-color: var(--resource-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.resource-page {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-gradient h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(100px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-table th:first-child,
|
||||||
|
.matrix-table td:first-child {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
444
frontend/src/resource-status/App.vue
Normal file
444
frontend/src/resource-status/App.vue
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
||||||
|
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||||
|
import { MATRIX_STATUS_COLUMNS, STATUS_DISPLAY_MAP, normalizeStatus } from '../resource-shared/constants.js';
|
||||||
|
|
||||||
|
import EquipmentGrid from './components/EquipmentGrid.vue';
|
||||||
|
import FilterBar from './components/FilterBar.vue';
|
||||||
|
import FloatingTooltip from './components/FloatingTooltip.vue';
|
||||||
|
import MatrixSection from './components/MatrixSection.vue';
|
||||||
|
import StatusHeader from './components/StatusHeader.vue';
|
||||||
|
import SummaryCards from './components/SummaryCards.vue';
|
||||||
|
|
||||||
|
ensureMesApiAvailable();
|
||||||
|
|
||||||
|
const API_TIMEOUT = 60000;
|
||||||
|
|
||||||
|
const allEquipment = ref([]);
|
||||||
|
const workcenterGroups = ref([]);
|
||||||
|
const summary = ref({
|
||||||
|
totalCount: 0,
|
||||||
|
byStatus: {
|
||||||
|
PRD: 0,
|
||||||
|
SBY: 0,
|
||||||
|
UDT: 0,
|
||||||
|
SDT: 0,
|
||||||
|
EGT: 0,
|
||||||
|
NST: 0,
|
||||||
|
OTHER: 0,
|
||||||
|
},
|
||||||
|
ouPct: 0,
|
||||||
|
availabilityPct: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterState = reactive({
|
||||||
|
group: '',
|
||||||
|
isProduction: false,
|
||||||
|
isKey: false,
|
||||||
|
isMonitor: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matrixFilter = ref(null);
|
||||||
|
const summaryStatusFilter = ref(null);
|
||||||
|
const hierarchyState = reactive({});
|
||||||
|
|
||||||
|
const loading = reactive({
|
||||||
|
initial: true,
|
||||||
|
refreshing: false,
|
||||||
|
options: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheLevel = ref('loading');
|
||||||
|
const cacheText = ref('檢查中...');
|
||||||
|
const lastUpdate = ref('--');
|
||||||
|
|
||||||
|
const summaryError = ref('');
|
||||||
|
const equipmentError = ref('');
|
||||||
|
|
||||||
|
const tooltipState = reactive({
|
||||||
|
visible: false,
|
||||||
|
type: 'lot',
|
||||||
|
payload: null,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function unwrapApiResult(result, fallbackMessage) {
|
||||||
|
if (result?.success === true) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
if (result?.success === false) {
|
||||||
|
throw new Error(result.error || fallbackMessage);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilterParams() {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (filterState.group) {
|
||||||
|
params.workcenter_groups = filterState.group;
|
||||||
|
}
|
||||||
|
if (filterState.isProduction) {
|
||||||
|
params.is_production = 1;
|
||||||
|
}
|
||||||
|
if (filterState.isKey) {
|
||||||
|
params.is_key = 1;
|
||||||
|
}
|
||||||
|
if (filterState.isMonitor) {
|
||||||
|
params.is_monitor = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHierarchyState() {
|
||||||
|
Object.keys(hierarchyState).forEach((key) => {
|
||||||
|
delete hierarchyState[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
loading.options = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiGet('/api/resource/status/options', {
|
||||||
|
timeout: API_TIMEOUT,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
const data = unwrapApiResult(result, '載入篩選選項失敗');
|
||||||
|
workcenterGroups.value = Array.isArray(data?.workcenter_groups) ? data.workcenter_groups : [];
|
||||||
|
} finally {
|
||||||
|
loading.options = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
const result = await apiGet('/api/resource/status/summary', {
|
||||||
|
params: buildFilterParams(),
|
||||||
|
timeout: API_TIMEOUT,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = unwrapApiResult(result, '載入摘要失敗');
|
||||||
|
const byStatus = data?.by_status || {};
|
||||||
|
|
||||||
|
const normalizedStatus = Object.fromEntries(
|
||||||
|
MATRIX_STATUS_COLUMNS.map((status) => [status, Number(byStatus[status] || 0)])
|
||||||
|
);
|
||||||
|
|
||||||
|
summary.value = {
|
||||||
|
totalCount: Number(data?.total_count || 0),
|
||||||
|
byStatus: normalizedStatus,
|
||||||
|
ouPct: Number(data?.ou_pct || 0),
|
||||||
|
availabilityPct: Number(data?.availability_pct || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEquipment() {
|
||||||
|
const result = await apiGet('/api/resource/status', {
|
||||||
|
params: buildFilterParams(),
|
||||||
|
timeout: API_TIMEOUT,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = unwrapApiResult(result, '載入設備資料失敗');
|
||||||
|
|
||||||
|
allEquipment.value = Array.isArray(data) ? data : [];
|
||||||
|
matrixFilter.value = null;
|
||||||
|
summaryStatusFilter.value = null;
|
||||||
|
resetHierarchyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCacheStatus() {
|
||||||
|
try {
|
||||||
|
const health = await apiGet('/health', {
|
||||||
|
timeout: 15000,
|
||||||
|
retries: 0,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceCache = health?.resource_cache || {};
|
||||||
|
const equipmentCache = health?.equipment_status_cache || {};
|
||||||
|
|
||||||
|
if (resourceCache.enabled && resourceCache.loaded) {
|
||||||
|
cacheLevel.value = 'ok';
|
||||||
|
cacheText.value = `快取正常 (${Number(resourceCache.count || 0)} 筆)`;
|
||||||
|
} else if (resourceCache.enabled) {
|
||||||
|
cacheLevel.value = 'loading';
|
||||||
|
cacheText.value = '快取載入中...';
|
||||||
|
} else {
|
||||||
|
cacheLevel.value = 'error';
|
||||||
|
cacheText.value = '快取未啟用';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipmentCache.updated_at) {
|
||||||
|
lastUpdate.value = new Date(equipmentCache.updated_at).toLocaleString('zh-TW');
|
||||||
|
} else {
|
||||||
|
lastUpdate.value = '--';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
cacheLevel.value = 'error';
|
||||||
|
cacheText.value = '無法連線';
|
||||||
|
lastUpdate.value = '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatrixFilterLabel(filter) {
|
||||||
|
if (!filter) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [filter.workcenter_group];
|
||||||
|
if (filter.family) {
|
||||||
|
parts.push(filter.family);
|
||||||
|
}
|
||||||
|
if (filter.resource) {
|
||||||
|
const resource = allEquipment.value.find((item) => item.RESOURCEID === filter.resource);
|
||||||
|
parts.push(resource?.RESOURCENAME || filter.resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(STATUS_DISPLAY_MAP[filter.status] || filter.status);
|
||||||
|
return `矩陣篩選: ${parts.join(' / ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameMatrixFilter(left, right) {
|
||||||
|
if (!left || !right) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(left.workcenter_group || null) === (right.workcenter_group || null) &&
|
||||||
|
(left.status || null) === (right.status || null) &&
|
||||||
|
(left.family || null) === (right.family || null) &&
|
||||||
|
(left.resource || null) === (right.resource || null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchMatrixFilter(eq, filter) {
|
||||||
|
if (!filter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((eq.WORKCENTER_GROUP || 'UNKNOWN') !== filter.workcenter_group) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.family && (eq.RESOURCEFAMILYNAME || 'UNKNOWN') !== filter.family) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.resource && (eq.RESOURCEID || null) !== filter.resource) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeStatus(eq.EQUIPMENTASSETSSTATUS) === filter.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedEquipment = computed(() => {
|
||||||
|
return allEquipment.value.filter((eq) => {
|
||||||
|
if (matrixFilter.value && !matchMatrixFilter(eq, matrixFilter.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (summaryStatusFilter.value && normalizeStatus(eq.EQUIPMENTASSETSSTATUS) !== summaryStatusFilter.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeFilterText = computed(() => {
|
||||||
|
const labels = [];
|
||||||
|
|
||||||
|
const matrixLabel = buildMatrixFilterLabel(matrixFilter.value);
|
||||||
|
if (matrixLabel) {
|
||||||
|
labels.push(matrixLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summaryStatusFilter.value) {
|
||||||
|
labels.push(`卡片篩選: ${STATUS_DISPLAY_MAP[summaryStatusFilter.value] || summaryStatusFilter.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels.join(' | ');
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyMatrixFilter(nextFilter) {
|
||||||
|
if (isSameMatrixFilter(matrixFilter.value, nextFilter)) {
|
||||||
|
matrixFilter.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matrixFilter.value = {
|
||||||
|
workcenter_group: nextFilter.workcenter_group,
|
||||||
|
status: nextFilter.status,
|
||||||
|
family: nextFilter.family || null,
|
||||||
|
resource: nextFilter.resource || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllEquipmentFilters() {
|
||||||
|
matrixFilter.value = null;
|
||||||
|
summaryStatusFilter.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSummaryStatus(status) {
|
||||||
|
summaryStatusFilter.value = summaryStatusFilter.value === status ? null : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleRow(rowId) {
|
||||||
|
hierarchyState[rowId] = !hierarchyState[rowId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleAllRows({ expand, rowIds }) {
|
||||||
|
(rowIds || []).forEach((rowId) => {
|
||||||
|
hierarchyState[rowId] = Boolean(expand);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTooltip() {
|
||||||
|
tooltipState.visible = false;
|
||||||
|
tooltipState.payload = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLotTooltip({ x, y, equipment }) {
|
||||||
|
const lotDetails = equipment?.LOT_DETAILS;
|
||||||
|
if (!Array.isArray(lotDetails) || lotDetails.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltipState.type = 'lot';
|
||||||
|
tooltipState.payload = lotDetails;
|
||||||
|
tooltipState.position = { x, y };
|
||||||
|
tooltipState.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openJobTooltip({ x, y, equipment }) {
|
||||||
|
if (!equipment?.JOBORDER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltipState.type = 'job';
|
||||||
|
tooltipState.payload = equipment;
|
||||||
|
tooltipState.position = { x, y };
|
||||||
|
tooltipState.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(showOverlay = false) {
|
||||||
|
if (showOverlay) {
|
||||||
|
loading.initial = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.refreshing = true;
|
||||||
|
summaryError.value = '';
|
||||||
|
equipmentError.value = '';
|
||||||
|
|
||||||
|
const [summaryResult, equipmentResult] = await Promise.allSettled([loadSummary(), loadEquipment()]);
|
||||||
|
await checkCacheStatus();
|
||||||
|
|
||||||
|
if (summaryResult.status === 'rejected') {
|
||||||
|
summaryError.value = summaryResult.reason?.message || '摘要資料載入失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipmentResult.status === 'rejected') {
|
||||||
|
equipmentError.value = equipmentResult.reason?.message || '設備資料載入失敗';
|
||||||
|
allEquipment.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.refreshing = false;
|
||||||
|
loading.initial = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyFiltersAndReload() {
|
||||||
|
closeTooltip();
|
||||||
|
await loadData(false);
|
||||||
|
resetAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroup(group) {
|
||||||
|
filterState.group = group || '';
|
||||||
|
void applyFiltersAndReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFlags(nextFlags) {
|
||||||
|
filterState.isProduction = Boolean(nextFlags?.isProduction);
|
||||||
|
filterState.isKey = Boolean(nextFlags?.isKey);
|
||||||
|
filterState.isMonitor = Boolean(nextFlags?.isMonitor);
|
||||||
|
void applyFiltersAndReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resetAutoRefresh, triggerRefresh } = useAutoRefresh({
|
||||||
|
onRefresh: () => loadData(false),
|
||||||
|
intervalMs: 5 * 60 * 1000,
|
||||||
|
autoStart: true,
|
||||||
|
refreshOnVisible: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleManualRefresh() {
|
||||||
|
closeTooltip();
|
||||||
|
await triggerRefresh({ force: true, resetTimer: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPage() {
|
||||||
|
try {
|
||||||
|
await loadOptions();
|
||||||
|
} catch (error) {
|
||||||
|
equipmentError.value = error?.message || '載入篩選選項失敗';
|
||||||
|
}
|
||||||
|
await loadData(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initPage();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="resource-page">
|
||||||
|
<div class="dashboard">
|
||||||
|
<StatusHeader
|
||||||
|
:cache-level="cacheLevel"
|
||||||
|
:cache-text="cacheText"
|
||||||
|
:last-update="lastUpdate"
|
||||||
|
:refreshing="loading.refreshing"
|
||||||
|
@refresh="handleManualRefresh"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
:workcenter-groups="workcenterGroups"
|
||||||
|
:selected-group="filterState.group"
|
||||||
|
:flags="filterState"
|
||||||
|
:loading="loading.options || loading.refreshing"
|
||||||
|
@change-group="updateGroup"
|
||||||
|
@change-flags="updateFlags"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="summaryError" class="error-banner">{{ summaryError }}</p>
|
||||||
|
<SummaryCards :summary="summary" :active-status="summaryStatusFilter" @toggle-status="toggleSummaryStatus" />
|
||||||
|
|
||||||
|
<p v-if="equipmentError" class="error-banner">{{ equipmentError }}</p>
|
||||||
|
<MatrixSection
|
||||||
|
:equipment="allEquipment"
|
||||||
|
:expanded-state="hierarchyState"
|
||||||
|
:matrix-filter="matrixFilter"
|
||||||
|
@toggle-row="handleToggleRow"
|
||||||
|
@toggle-all="handleToggleAllRows"
|
||||||
|
@cell-filter="applyMatrixFilter"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EquipmentGrid
|
||||||
|
:equipment="displayedEquipment"
|
||||||
|
:active-filter-text="activeFilterText"
|
||||||
|
@clear-filter="clearAllEquipmentFilters"
|
||||||
|
@show-lot="openLotTooltip"
|
||||||
|
@show-job="openJobTooltip"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-overlay" :class="{ hidden: !loading.initial }">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FloatingTooltip
|
||||||
|
:visible="tooltipState.visible"
|
||||||
|
:type="tooltipState.type"
|
||||||
|
:payload="tooltipState.payload"
|
||||||
|
:position="tooltipState.position"
|
||||||
|
@close="closeTooltip"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
81
frontend/src/resource-status/components/EquipmentCard.vue
Normal file
81
frontend/src/resource-status/components/EquipmentCard.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { getStatusDisplay, normalizeStatus } from '../../resource-shared/constants.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
equipment: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['show-lot', 'show-job']);
|
||||||
|
|
||||||
|
const statusKey = computed(() => normalizeStatus(props.equipment.EQUIPMENTASSETSSTATUS));
|
||||||
|
|
||||||
|
const statusClass = computed(() => {
|
||||||
|
const category = String(props.equipment.STATUS_CATEGORY || '').toLowerCase();
|
||||||
|
if (category) {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
return statusKey.value.toLowerCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = computed(() => getStatusDisplay(props.equipment.EQUIPMENTASSETSSTATUS));
|
||||||
|
const lotCount = computed(() => Number(props.equipment.LOT_COUNT || 0));
|
||||||
|
const hasJob = computed(() => Boolean(props.equipment.JOBORDER));
|
||||||
|
|
||||||
|
function emitLot(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
emit('show-lot', {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
equipment: props.equipment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitJob(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
emit('show-job', {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
equipment: props.equipment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="equipment-card" :class="`status-${statusClass}`">
|
||||||
|
<div class="eq-header">
|
||||||
|
<div class="eq-name">{{ equipment.RESOURCENAME || equipment.RESOURCEID || '--' }}</div>
|
||||||
|
<span class="eq-status" :class="statusClass">{{ statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="eq-info">
|
||||||
|
<span class="eq-info-item">
|
||||||
|
<span class="label">工站</span>
|
||||||
|
<span class="value">{{ equipment.WORKCENTERNAME || '--' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="eq-info-item">
|
||||||
|
<span class="label">群組</span>
|
||||||
|
<span class="value">{{ equipment.WORKCENTER_GROUP || '--' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="eq-info-item">
|
||||||
|
<span class="label">型號</span>
|
||||||
|
<span class="value">{{ equipment.RESOURCEFAMILYNAME || '--' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="eq-info-item">
|
||||||
|
<span class="label">位置</span>
|
||||||
|
<span class="value">{{ equipment.LOCATIONNAME || '--' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-triggers">
|
||||||
|
<button v-if="lotCount > 0" type="button" class="info-trigger" @click="emitLot">LOT {{ lotCount }}</button>
|
||||||
|
<button v-if="hasJob" type="button" class="info-trigger" @click="emitJob">
|
||||||
|
JOB {{ equipment.JOBORDER }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
43
frontend/src/resource-status/components/EquipmentGrid.vue
Normal file
43
frontend/src/resource-status/components/EquipmentGrid.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import EquipmentCard from './EquipmentCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
equipment: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
activeFilterText: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['clear-filter', 'show-lot', 'show-job']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section-card">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">設備清單</h2>
|
||||||
|
<span class="equipment-count">共 {{ equipment.length }} 台</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-indicator" :class="{ active: Boolean(activeFilterText) }">
|
||||||
|
<span class="filter-text">{{ activeFilterText }}</span>
|
||||||
|
<button type="button" class="btn-sm" @click="$emit('clear-filter')">清除篩選</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="equipment.length" class="equipment-grid">
|
||||||
|
<EquipmentCard
|
||||||
|
v-for="eq in equipment"
|
||||||
|
:key="eq.RESOURCEID || eq.RESOURCENAME"
|
||||||
|
:equipment="eq"
|
||||||
|
@show-lot="$emit('show-lot', $event)"
|
||||||
|
@show-job="$emit('show-job', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">無符合條件的設備</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
85
frontend/src/resource-status/components/FilterBar.vue
Normal file
85
frontend/src/resource-status/components/FilterBar.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
workcenterGroups: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
selectedGroup: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
isProduction: false,
|
||||||
|
isKey: false,
|
||||||
|
isMonitor: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change-group', 'change-flags']);
|
||||||
|
|
||||||
|
function updateFlag(key, checked) {
|
||||||
|
emit('change-flags', {
|
||||||
|
...props.flags,
|
||||||
|
[key]: checked,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section-card">
|
||||||
|
<div class="filters-panel">
|
||||||
|
<div class="filter-block">
|
||||||
|
<label for="status-group-filter">工站群組</label>
|
||||||
|
<select
|
||||||
|
id="status-group-filter"
|
||||||
|
class="filter-select"
|
||||||
|
:value="selectedGroup"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="$emit('change-group', $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="">全部群組</option>
|
||||||
|
<option v-for="group in workcenterGroups" :key="group" :value="group">
|
||||||
|
{{ group }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="filter-chip" :class="{ active: flags.isProduction }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="flags.isProduction"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="updateFlag('isProduction', $event.target.checked)"
|
||||||
|
/>
|
||||||
|
生產設備
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-chip" :class="{ active: flags.isKey }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="flags.isKey"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="updateFlag('isKey', $event.target.checked)"
|
||||||
|
/>
|
||||||
|
重點設備
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-chip" :class="{ active: flags.isMonitor }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="flags.isMonitor"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="updateFlag('isMonitor', $event.target.checked)"
|
||||||
|
/>
|
||||||
|
監控設備
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
186
frontend/src/resource-status/components/FloatingTooltip.vue
Normal file
186
frontend/src/resource-status/components/FloatingTooltip.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'lot',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: [Array, Object],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ x: 0, y: 0 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const tooltipRef = ref(null);
|
||||||
|
const tooltipStyle = reactive({ left: '0px', top: '0px' });
|
||||||
|
|
||||||
|
const tooltipTitle = computed(() => {
|
||||||
|
if (props.type === 'job') {
|
||||||
|
return 'JOB 詳細資訊';
|
||||||
|
}
|
||||||
|
return 'LOT 詳情';
|
||||||
|
});
|
||||||
|
|
||||||
|
const lotItems = computed(() => {
|
||||||
|
if (!Array.isArray(props.payload)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return props.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobFields = computed(() => {
|
||||||
|
if (!props.payload || Array.isArray(props.payload)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const eq = props.payload;
|
||||||
|
return [
|
||||||
|
{ label: 'JOBORDER', value: eq.JOBORDER, highlight: true },
|
||||||
|
{ label: 'JOBSTATUS', value: eq.JOBSTATUS, highlight: true },
|
||||||
|
{ label: 'MODEL', value: eq.JOBMODEL },
|
||||||
|
{ label: 'STAGE', value: eq.JOBSTAGE },
|
||||||
|
{ label: 'JOBID', value: eq.JOBID },
|
||||||
|
{ label: '建立時間', value: formatDate(eq.CREATEDATE) },
|
||||||
|
{ label: '建立人員', value: eq.CREATEUSERNAME || eq.CREATEUSER },
|
||||||
|
{ label: '技術員', value: eq.TECHNICIANUSERNAME || eq.TECHNICIANUSER },
|
||||||
|
{ label: '症狀碼', value: eq.SYMPTOMCODE },
|
||||||
|
{ label: '原因碼', value: eq.CAUSECODE },
|
||||||
|
{ label: '維修碼', value: eq.REPAIRCODE },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(rawValue) {
|
||||||
|
if (!rawValue) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Date(rawValue).toLocaleString('zh-TW');
|
||||||
|
} catch {
|
||||||
|
return String(rawValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionTooltip() {
|
||||||
|
if (!props.visible || !tooltipRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = 10;
|
||||||
|
const width = tooltipRef.value.offsetWidth;
|
||||||
|
const height = tooltipRef.value.offsetHeight;
|
||||||
|
|
||||||
|
let nextX = Number(props.position?.x || 0) + 12;
|
||||||
|
let nextY = Number(props.position?.y || 0) + 12;
|
||||||
|
|
||||||
|
if (nextX + width > window.innerWidth - padding) {
|
||||||
|
nextX = window.innerWidth - width - padding;
|
||||||
|
}
|
||||||
|
if (nextY + height > window.innerHeight - padding) {
|
||||||
|
nextY = window.innerHeight - height - padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextX = Math.max(padding, nextX);
|
||||||
|
nextY = Math.max(padding, nextY);
|
||||||
|
|
||||||
|
tooltipStyle.left = `${nextX}px`;
|
||||||
|
tooltipStyle.top = `${nextY}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutsideClick(event) {
|
||||||
|
if (!props.visible || !tooltipRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tooltipRef.value.contains(event.target)) {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindOverlayListeners() {
|
||||||
|
document.addEventListener('click', handleOutsideClick, true);
|
||||||
|
window.addEventListener('resize', positionTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindOverlayListeners() {
|
||||||
|
document.removeEventListener('click', handleOutsideClick, true);
|
||||||
|
window.removeEventListener('resize', positionTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.position?.x, props.position?.y, props.payload],
|
||||||
|
async ([visible]) => {
|
||||||
|
if (!visible) {
|
||||||
|
unbindOverlayListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
positionTooltip();
|
||||||
|
bindOverlayListeners();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unbindOverlayListeners();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" ref="tooltipRef" class="floating-tooltip" :style="tooltipStyle" @click.stop>
|
||||||
|
<div class="floating-tooltip-header">
|
||||||
|
<h3 class="floating-tooltip-title">{{ tooltipTitle }}</h3>
|
||||||
|
<button type="button" class="floating-tooltip-close" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="floating-tooltip-body">
|
||||||
|
<template v-if="type === 'lot'">
|
||||||
|
<template v-if="lotItems.length">
|
||||||
|
<article v-for="(lot, index) in lotItems" :key="`${lot.RUNCARDLOTID || 'lot'}-${index}`" class="lot-item">
|
||||||
|
<div class="lot-item-id">{{ lot.RUNCARDLOTID || '--' }}</div>
|
||||||
|
<div class="lot-grid">
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">QTY</span>
|
||||||
|
<span class="tooltip-field-value">{{ lot.LOTTRACKINQTY_PCS ?? '--' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Track-in</span>
|
||||||
|
<span class="tooltip-field-value">{{ formatDate(lot.LOTTRACKINTIME) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Employee</span>
|
||||||
|
<span class="tooltip-field-value">{{ lot.LOTTRACKINEMPLOYEE || '--' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
<div v-else class="tooltip-empty">無 LOT 明細</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="jobFields.length">
|
||||||
|
<div class="job-grid">
|
||||||
|
<div v-for="field in jobFields" :key="field.label" class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">{{ field.label }}</span>
|
||||||
|
<span class="tooltip-field-value" :class="{ highlight: field.highlight }">
|
||||||
|
{{ field.value || '--' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="tooltip-empty">無 JOB 明細</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
330
frontend/src/resource-status/components/MatrixSection.vue
Normal file
330
frontend/src/resource-status/components/MatrixSection.vue
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import HierarchyTable from '../../resource-shared/components/HierarchyTable.vue';
|
||||||
|
import {
|
||||||
|
MATRIX_STATUS_COLUMNS,
|
||||||
|
STATUS_DISPLAY_MAP,
|
||||||
|
normalizeStatus,
|
||||||
|
resolveOuBadgeClass,
|
||||||
|
} from '../../resource-shared/constants.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
equipment: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
expandedState: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
matrixFilter: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle-row', 'toggle-all', 'cell-filter']);
|
||||||
|
|
||||||
|
function createCounts() {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
PRD: 0,
|
||||||
|
SBY: 0,
|
||||||
|
UDT: 0,
|
||||||
|
SDT: 0,
|
||||||
|
EGT: 0,
|
||||||
|
NST: 0,
|
||||||
|
OTHER: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(value) {
|
||||||
|
return String(value || 'unknown').replace(/[^\w\u4e00-\u9fa5-]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResourceNode(eq, groupName, familyName, statusKey, index) {
|
||||||
|
const resourceId = eq.RESOURCEID || `resource_${index}`;
|
||||||
|
const statusRaw = String(eq.EQUIPMENTASSETSSTATUS || '--').toUpperCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `res_${normalizeKey(groupName)}_${normalizeKey(familyName)}_${normalizeKey(resourceId)}`,
|
||||||
|
level: 2,
|
||||||
|
name: eq.RESOURCENAME || eq.RESOURCEID || '--',
|
||||||
|
workcenterGroup: groupName,
|
||||||
|
family: familyName,
|
||||||
|
resource: eq.RESOURCEID || null,
|
||||||
|
statusKey,
|
||||||
|
statusRaw,
|
||||||
|
statusCategory: String(eq.STATUS_CATEGORY || '').toLowerCase(),
|
||||||
|
values: {
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
rowClickable: true,
|
||||||
|
rowPayload: {
|
||||||
|
workcenter_group: groupName,
|
||||||
|
status: statusKey,
|
||||||
|
family: familyName,
|
||||||
|
resource: eq.RESOURCEID || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcOuPct(counts) {
|
||||||
|
const denominator =
|
||||||
|
Number(counts.PRD || 0) +
|
||||||
|
Number(counts.SBY || 0) +
|
||||||
|
Number(counts.UDT || 0) +
|
||||||
|
Number(counts.SDT || 0) +
|
||||||
|
Number(counts.EGT || 0);
|
||||||
|
|
||||||
|
if (!denominator) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (Number(counts.PRD || 0) / denominator) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMatrixFilterMatch(filter, { group, status, family = null, resource = null }) {
|
||||||
|
if (!filter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameGroup = filter.workcenter_group === group;
|
||||||
|
const sameStatus = filter.status === status;
|
||||||
|
const sameFamily = (filter.family || null) === (family || null);
|
||||||
|
const sameResource = (filter.resource || null) === (resource || null);
|
||||||
|
|
||||||
|
return sameGroup && sameStatus && sameFamily && sameResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatrixHierarchy(equipment) {
|
||||||
|
const groupMap = new Map();
|
||||||
|
|
||||||
|
equipment.forEach((eq, index) => {
|
||||||
|
const groupName = eq.WORKCENTER_GROUP || 'UNKNOWN';
|
||||||
|
const familyName = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
|
||||||
|
const groupSeq = Number(eq.WORKCENTER_GROUP_SEQ ?? 999);
|
||||||
|
const statusKey = normalizeStatus(eq.EQUIPMENTASSETSSTATUS);
|
||||||
|
|
||||||
|
if (!groupMap.has(groupName)) {
|
||||||
|
groupMap.set(groupName, {
|
||||||
|
id: `grp_${normalizeKey(groupName)}`,
|
||||||
|
level: 0,
|
||||||
|
name: groupName,
|
||||||
|
workcenterGroup: groupName,
|
||||||
|
sequence: groupSeq,
|
||||||
|
counts: createCounts(),
|
||||||
|
children: [],
|
||||||
|
familyMap: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupNode = groupMap.get(groupName);
|
||||||
|
|
||||||
|
if (!groupNode.familyMap.has(familyName)) {
|
||||||
|
const familyNode = {
|
||||||
|
id: `fam_${normalizeKey(groupName)}_${normalizeKey(familyName)}`,
|
||||||
|
level: 1,
|
||||||
|
name: familyName,
|
||||||
|
workcenterGroup: groupName,
|
||||||
|
family: familyName,
|
||||||
|
counts: createCounts(),
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
groupNode.familyMap.set(familyName, familyNode);
|
||||||
|
groupNode.children.push(familyNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const familyNode = groupNode.familyMap.get(familyName);
|
||||||
|
familyNode.children.push(buildResourceNode(eq, groupName, familyName, statusKey, index));
|
||||||
|
|
||||||
|
groupNode.counts.total += 1;
|
||||||
|
groupNode.counts[statusKey] += 1;
|
||||||
|
familyNode.counts.total += 1;
|
||||||
|
familyNode.counts[statusKey] += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = [...groupMap.values()]
|
||||||
|
.map((groupNode) => {
|
||||||
|
groupNode.children.sort((left, right) => {
|
||||||
|
const totalDiff = Number(right.counts.total || 0) - Number(left.counts.total || 0);
|
||||||
|
if (totalDiff !== 0) {
|
||||||
|
return totalDiff;
|
||||||
|
}
|
||||||
|
return String(left.name).localeCompare(String(right.name), 'zh-Hant');
|
||||||
|
});
|
||||||
|
|
||||||
|
groupNode.children.forEach((familyNode) => {
|
||||||
|
familyNode.selectedColumns = Object.fromEntries(
|
||||||
|
MATRIX_STATUS_COLUMNS.map((status) => [
|
||||||
|
status,
|
||||||
|
isMatrixFilterMatch(props.matrixFilter, {
|
||||||
|
group: familyNode.workcenterGroup,
|
||||||
|
family: familyNode.family,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
familyNode.children.forEach((resourceNode) => {
|
||||||
|
resourceNode.rowSelected = isMatrixFilterMatch(props.matrixFilter, {
|
||||||
|
group: resourceNode.workcenterGroup,
|
||||||
|
family: resourceNode.family,
|
||||||
|
resource: resourceNode.resource,
|
||||||
|
status: resourceNode.statusKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
groupNode.selectedColumns = Object.fromEntries(
|
||||||
|
MATRIX_STATUS_COLUMNS.map((status) => [
|
||||||
|
status,
|
||||||
|
isMatrixFilterMatch(props.matrixFilter, {
|
||||||
|
group: groupNode.workcenterGroup,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
delete groupNode.familyMap;
|
||||||
|
return groupNode;
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const seqDiff = Number(left.sequence || 999) - Number(right.sequence || 999);
|
||||||
|
if (seqDiff !== 0) {
|
||||||
|
return seqDiff;
|
||||||
|
}
|
||||||
|
return String(left.name).localeCompare(String(right.name), 'zh-Hant');
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchy = computed(() => buildMatrixHierarchy(props.equipment || []));
|
||||||
|
|
||||||
|
function resolveEquipmentStatusClass(node) {
|
||||||
|
if (node.statusCategory) {
|
||||||
|
return `eq-status ${node.statusCategory}`;
|
||||||
|
}
|
||||||
|
return `eq-status ${String(node.statusKey || 'other').toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
key: 'total',
|
||||||
|
label: '總數',
|
||||||
|
className: 'col-total',
|
||||||
|
value: (node) => {
|
||||||
|
if (node.level === 2) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Number(node.counts?.total || 0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusColumns = MATRIX_STATUS_COLUMNS.map((status) => {
|
||||||
|
const className = `col-${status.toLowerCase()}`;
|
||||||
|
return {
|
||||||
|
key: status,
|
||||||
|
label: status,
|
||||||
|
className,
|
||||||
|
render: (node) => {
|
||||||
|
if (node.level === 2) {
|
||||||
|
const active = node.statusKey === status;
|
||||||
|
return {
|
||||||
|
text: active ? '●' : '-',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: Number(node.counts?.[status] || 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cellClass: (node) => {
|
||||||
|
if (node.level === 2) {
|
||||||
|
return node.statusKey === status ? '' : 'zero';
|
||||||
|
}
|
||||||
|
return Number(node.counts?.[status] || 0) === 0 ? 'zero' : '';
|
||||||
|
},
|
||||||
|
isClickable: (node) => node.level < 2,
|
||||||
|
isSelected: (node) => Boolean(node.selectedColumns?.[status]),
|
||||||
|
payload: (node) => {
|
||||||
|
if (node.level === 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workcenter_group: node.workcenterGroup,
|
||||||
|
family: node.level === 1 ? node.family : null,
|
||||||
|
resource: null,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const ouColumn = {
|
||||||
|
key: 'ou',
|
||||||
|
label: 'OU%',
|
||||||
|
render: (node) => {
|
||||||
|
if (node.level === 2) {
|
||||||
|
return {
|
||||||
|
text: STATUS_DISPLAY_MAP[node.statusKey] || node.statusRaw || '--',
|
||||||
|
badgeClass: resolveEquipmentStatusClass(node),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ouValue = calcOuPct(node.counts || {});
|
||||||
|
return {
|
||||||
|
text: `${ouValue.toFixed(1)}%`,
|
||||||
|
badgeClass: `ou-badge ${resolveOuBadgeClass(ouValue)}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...baseColumns, ...statusColumns, ouColumn];
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCellClick({ payload }) {
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('cell-filter', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleAll(expand) {
|
||||||
|
const rowIds = [];
|
||||||
|
hierarchy.value.forEach((groupNode) => {
|
||||||
|
rowIds.push(groupNode.id);
|
||||||
|
groupNode.children.forEach((familyNode) => {
|
||||||
|
rowIds.push(familyNode.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('toggle-all', { expand, rowIds });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section-card">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">狀態矩陣</h2>
|
||||||
|
<div class="section-actions">
|
||||||
|
<button type="button" class="btn-sm" @click="handleToggleAll(true)">全部展開</button>
|
||||||
|
<button type="button" class="btn-sm" @click="handleToggleAll(false)">全部收合</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HierarchyTable
|
||||||
|
:hierarchy="hierarchy"
|
||||||
|
:columns="columns"
|
||||||
|
:expanded-state="expandedState"
|
||||||
|
name-column-label="工站群組 / 型號 / 設備"
|
||||||
|
empty-text="無符合條件的矩陣資料"
|
||||||
|
@toggle-row="$emit('toggle-row', $event)"
|
||||||
|
@cell-click="handleCellClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
50
frontend/src/resource-status/components/StatusHeader.vue
Normal file
50
frontend/src/resource-status/components/StatusHeader.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
cacheLevel: {
|
||||||
|
type: String,
|
||||||
|
default: 'loading',
|
||||||
|
},
|
||||||
|
cacheText: {
|
||||||
|
type: String,
|
||||||
|
default: '檢查中...',
|
||||||
|
},
|
||||||
|
lastUpdate: {
|
||||||
|
type: String,
|
||||||
|
default: '--',
|
||||||
|
},
|
||||||
|
refreshing: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['refresh']);
|
||||||
|
|
||||||
|
const cacheDotClass = computed(() => {
|
||||||
|
if (props.cacheLevel === 'ok') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (props.cacheLevel === 'error') {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
return 'loading';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="header-gradient">
|
||||||
|
<h1>設備即時概況</h1>
|
||||||
|
<div class="status-header-meta">
|
||||||
|
<div class="cache-status">
|
||||||
|
<span class="cache-dot" :class="cacheDotClass"></span>
|
||||||
|
<span>{{ cacheText }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="last-update">更新: {{ lastUpdate }}</span>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="refreshing" @click="$emit('refresh')">
|
||||||
|
重新整理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
123
frontend/src/resource-status/components/SummaryCards.vue
Normal file
123
frontend/src/resource-status/components/SummaryCards.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MATRIX_STATUS_COLUMNS,
|
||||||
|
OU_BADGE_THRESHOLDS,
|
||||||
|
STATUS_DISPLAY_MAP,
|
||||||
|
} from '../../resource-shared/constants.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
summary: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
totalCount: 0,
|
||||||
|
byStatus: {},
|
||||||
|
ouPct: 0,
|
||||||
|
availabilityPct: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
activeStatus: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle-status']);
|
||||||
|
|
||||||
|
const totalForPct = computed(() => {
|
||||||
|
return MATRIX_STATUS_COLUMNS.reduce((total, status) => total + Number(props.summary.byStatus?.[status] || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatPct(count) {
|
||||||
|
if (!totalForPct.value) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return `${((Number(count || 0) / totalForPct.value) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOuClass(value) {
|
||||||
|
const pct = Number(value || 0);
|
||||||
|
if (pct >= OU_BADGE_THRESHOLDS.high) {
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
if (pct >= OU_BADGE_THRESHOLDS.medium) {
|
||||||
|
return 'medium';
|
||||||
|
}
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = computed(() => {
|
||||||
|
const byStatus = props.summary.byStatus || {};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'OU',
|
||||||
|
label: 'OU%',
|
||||||
|
value: `${Number(props.summary.ouPct || 0).toFixed(1)}%`,
|
||||||
|
className: 'ou',
|
||||||
|
sub: '稼動率',
|
||||||
|
clickable: false,
|
||||||
|
badgeClass: resolveOuClass(props.summary.ouPct),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AVAIL',
|
||||||
|
label: 'AVAIL%',
|
||||||
|
value: `${Number(props.summary.availabilityPct || 0).toFixed(1)}%`,
|
||||||
|
className: 'availability',
|
||||||
|
sub: '可用率',
|
||||||
|
clickable: false,
|
||||||
|
badgeClass: resolveOuClass(props.summary.availabilityPct),
|
||||||
|
},
|
||||||
|
...MATRIX_STATUS_COLUMNS.map((status) => ({
|
||||||
|
key: status,
|
||||||
|
label: status,
|
||||||
|
value: Number(byStatus[status] || 0),
|
||||||
|
className: status.toLowerCase(),
|
||||||
|
sub: `${STATUS_DISPLAY_MAP[status] || status} (${formatPct(byStatus[status])})`,
|
||||||
|
clickable: true,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
key: 'TOTAL',
|
||||||
|
label: 'Total',
|
||||||
|
value: Number(props.summary.totalCount || 0),
|
||||||
|
className: 'total',
|
||||||
|
sub: '設備總數',
|
||||||
|
clickable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCardClick(card) {
|
||||||
|
if (!card.clickable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('toggle-status', card.key);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="section-card">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="summary-grid">
|
||||||
|
<article
|
||||||
|
v-for="card in cards"
|
||||||
|
:key="card.key"
|
||||||
|
class="summary-card"
|
||||||
|
:class="[
|
||||||
|
card.className,
|
||||||
|
{ clickable: card.clickable, active: card.clickable && activeStatus === card.key },
|
||||||
|
]"
|
||||||
|
@click="handleCardClick(card)"
|
||||||
|
>
|
||||||
|
<div class="summary-label">{{ card.label }}</div>
|
||||||
|
<div class="summary-value" :class="card.className">
|
||||||
|
<span v-if="card.badgeClass" class="ou-badge" :class="card.badgeClass">{{ card.value }}</span>
|
||||||
|
<template v-else>{{ card.value }}</template>
|
||||||
|
</div>
|
||||||
|
<div class="summary-sub">{{ card.sub }}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
12
frontend/src/resource-status/index.html
Normal file
12
frontend/src/resource-status/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>設備即時概況</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,853 +1,7 @@
|
|||||||
import { ensureMesApiAvailable } from '../core/api.js';
|
import { createApp } from 'vue';
|
||||||
import { getPageContract } from '../core/field-contracts.js';
|
|
||||||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
|
||||||
import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js';
|
|
||||||
|
|
||||||
ensureMesApiAvailable();
|
import App from './App.vue';
|
||||||
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
|
import '../resource-shared/styles.css';
|
||||||
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
|
import './style.css';
|
||||||
window.__FIELD_CONTRACTS__['resource_status:matrix_summary'] = getPageContract('resource_status', 'matrix_summary');
|
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
let allEquipment = [];
|
|
||||||
let workcenterGroups = [];
|
|
||||||
let matrixFilter = null; // { workcenter_group, status }
|
|
||||||
let matrixHierarchyState = {}; // Track expanded/collapsed state for matrix rows
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Hierarchical Matrix Functions
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function buildMatrixHierarchy(equipment) {
|
|
||||||
// Build hierarchy: workcenter_group -> resourcefamily -> equipment
|
|
||||||
const groupMap = {};
|
|
||||||
|
|
||||||
equipment.forEach(eq => {
|
|
||||||
const group = eq.WORKCENTER_GROUP || 'UNKNOWN';
|
|
||||||
const family = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
|
|
||||||
const status = eq.EQUIPMENTASSETSSTATUS || 'OTHER';
|
|
||||||
const groupSeq = eq.WORKCENTER_GROUP_SEQ ?? 999;
|
|
||||||
|
|
||||||
// Initialize group
|
|
||||||
if (!groupMap[group]) {
|
|
||||||
groupMap[group] = {
|
|
||||||
name: group,
|
|
||||||
sequence: groupSeq,
|
|
||||||
families: {},
|
|
||||||
counts: { total: 0, PRD: 0, SBY: 0, UDT: 0, SDT: 0, EGT: 0, NST: 0, OTHER: 0 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize family
|
|
||||||
if (!groupMap[group].families[family]) {
|
|
||||||
groupMap[group].families[family] = {
|
|
||||||
name: family,
|
|
||||||
equipment: [],
|
|
||||||
counts: { total: 0, PRD: 0, SBY: 0, UDT: 0, SDT: 0, EGT: 0, NST: 0, OTHER: 0 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add equipment to family
|
|
||||||
groupMap[group].families[family].equipment.push(eq);
|
|
||||||
|
|
||||||
// Map status to count key
|
|
||||||
let statusKey = 'OTHER';
|
|
||||||
if (['PRD'].includes(status)) statusKey = 'PRD';
|
|
||||||
else if (['SBY'].includes(status)) statusKey = 'SBY';
|
|
||||||
else if (['UDT', 'PM', 'BKD'].includes(status)) statusKey = 'UDT';
|
|
||||||
else if (['SDT'].includes(status)) statusKey = 'SDT';
|
|
||||||
else if (['EGT', 'ENG'].includes(status)) statusKey = 'EGT';
|
|
||||||
else if (['NST', 'OFF'].includes(status)) statusKey = 'NST';
|
|
||||||
|
|
||||||
// Update counts
|
|
||||||
groupMap[group].counts.total++;
|
|
||||||
groupMap[group].counts[statusKey]++;
|
|
||||||
groupMap[group].families[family].counts.total++;
|
|
||||||
groupMap[group].families[family].counts[statusKey]++;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert to array structure
|
|
||||||
// Sort groups by sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
|
|
||||||
// Sort families by total count descending
|
|
||||||
const hierarchy = Object.values(groupMap).map(g => ({
|
|
||||||
...g,
|
|
||||||
families: Object.values(g.families).sort((a, b) => b.counts.total - a.counts.total)
|
|
||||||
})).sort((a, b) => a.sequence - b.sequence);
|
|
||||||
|
|
||||||
return hierarchy;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMatrixRow(rowId) {
|
|
||||||
matrixHierarchyState[rowId] = !matrixHierarchyState[rowId];
|
|
||||||
renderMatrixHierarchy();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAllMatrixRows(expand) {
|
|
||||||
const hierarchy = buildMatrixHierarchy(allEquipment);
|
|
||||||
hierarchy.forEach(group => {
|
|
||||||
matrixHierarchyState[`grp_${group.name}`] = expand;
|
|
||||||
group.families.forEach(fam => {
|
|
||||||
matrixHierarchyState[`fam_${group.name}_${fam.name}`] = expand;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
renderMatrixHierarchy();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMatrixHierarchy() {
|
|
||||||
const container = document.getElementById('matrixContainer');
|
|
||||||
const hierarchy = buildMatrixHierarchy(allEquipment);
|
|
||||||
|
|
||||||
if (hierarchy.length === 0) {
|
|
||||||
container.innerHTML = '<div class="empty-state">無資料</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = `
|
|
||||||
<table class="matrix-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>工站群組 / 型號 / 機台</th>
|
|
||||||
<th>總數</th>
|
|
||||||
<th>PRD</th>
|
|
||||||
<th>SBY</th>
|
|
||||||
<th>UDT</th>
|
|
||||||
<th>SDT</th>
|
|
||||||
<th>EGT</th>
|
|
||||||
<th>NST</th>
|
|
||||||
<th>OTHER</th>
|
|
||||||
<th>OU%</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
`;
|
|
||||||
|
|
||||||
hierarchy.forEach(group => {
|
|
||||||
const grpId = `grp_${group.name}`;
|
|
||||||
const isGroupExpanded = matrixHierarchyState[grpId];
|
|
||||||
const hasChildren = group.families.length > 0;
|
|
||||||
|
|
||||||
// Calculate OU%
|
|
||||||
const avail = group.counts.PRD + group.counts.SBY + group.counts.UDT + group.counts.SDT + group.counts.EGT;
|
|
||||||
const ou = avail > 0 ? ((group.counts.PRD / avail) * 100).toFixed(1) : 0;
|
|
||||||
const ouClass = ou >= 80 ? 'high' : (ou >= 50 ? 'medium' : 'low');
|
|
||||||
|
|
||||||
// Group row (Level 0)
|
|
||||||
const expandBtn = hasChildren
|
|
||||||
? `<button class="expand-btn ${isGroupExpanded ? 'expanded' : ''}" onclick="toggleMatrixRow('${grpId}')">▶</button>`
|
|
||||||
: '<span class="expand-placeholder"></span>';
|
|
||||||
|
|
||||||
// Helper to check if this cell is selected (supports all levels)
|
|
||||||
const isSelected = (wg, st, fam = null, res = null) => {
|
|
||||||
if (!matrixFilter) return false;
|
|
||||||
if (matrixFilter.workcenter_group !== wg) return false;
|
|
||||||
if (matrixFilter.status !== st) return false;
|
|
||||||
if (fam !== null && matrixFilter.family !== fam) return false;
|
|
||||||
if (res !== null && matrixFilter.resource !== res) return false;
|
|
||||||
// Match level: if matrixFilter has family but we're checking group level, no match
|
|
||||||
if (matrixFilter.family && fam === null) return false;
|
|
||||||
if (matrixFilter.resource && res === null) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
const grpName = group.name;
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<tr class="row-level-0">
|
|
||||||
<td><span class="row-name">${expandBtn}${group.name}</span></td>
|
|
||||||
<td class="col-total">${group.counts.total}</td>
|
|
||||||
<td class="col-prd clickable ${group.counts.PRD === 0 ? 'zero' : ''} ${isSelected(grpName, 'PRD') ? 'selected' : ''}" data-wg="${grpName}" data-status="PRD" onclick="filterByMatrixCell('${grpName}', 'PRD')">${group.counts.PRD}</td>
|
|
||||||
<td class="col-sby clickable ${group.counts.SBY === 0 ? 'zero' : ''} ${isSelected(grpName, 'SBY') ? 'selected' : ''}" data-wg="${grpName}" data-status="SBY" onclick="filterByMatrixCell('${grpName}', 'SBY')">${group.counts.SBY}</td>
|
|
||||||
<td class="col-udt clickable ${group.counts.UDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'UDT') ? 'selected' : ''}" data-wg="${grpName}" data-status="UDT" onclick="filterByMatrixCell('${grpName}', 'UDT')">${group.counts.UDT}</td>
|
|
||||||
<td class="col-sdt clickable ${group.counts.SDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'SDT') ? 'selected' : ''}" data-wg="${grpName}" data-status="SDT" onclick="filterByMatrixCell('${grpName}', 'SDT')">${group.counts.SDT}</td>
|
|
||||||
<td class="col-egt clickable ${group.counts.EGT === 0 ? 'zero' : ''} ${isSelected(grpName, 'EGT') ? 'selected' : ''}" data-wg="${grpName}" data-status="EGT" onclick="filterByMatrixCell('${grpName}', 'EGT')">${group.counts.EGT}</td>
|
|
||||||
<td class="col-nst clickable ${group.counts.NST === 0 ? 'zero' : ''} ${isSelected(grpName, 'NST') ? 'selected' : ''}" data-wg="${grpName}" data-status="NST" onclick="filterByMatrixCell('${grpName}', 'NST')">${group.counts.NST}</td>
|
|
||||||
<td class="col-other clickable ${group.counts.OTHER === 0 ? 'zero' : ''} ${isSelected(grpName, 'OTHER') ? 'selected' : ''}" data-wg="${grpName}" data-status="OTHER" onclick="filterByMatrixCell('${grpName}', 'OTHER')">${group.counts.OTHER}</td>
|
|
||||||
<td><span class="ou-badge ${ouClass}">${ou}%</span></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Family rows (Level 1)
|
|
||||||
if (isGroupExpanded) {
|
|
||||||
group.families.forEach(fam => {
|
|
||||||
const famId = `fam_${group.name}_${fam.name}`;
|
|
||||||
const isFamExpanded = matrixHierarchyState[famId];
|
|
||||||
const hasEquipment = fam.equipment.length > 0;
|
|
||||||
|
|
||||||
const famAvail = fam.counts.PRD + fam.counts.SBY + fam.counts.UDT + fam.counts.SDT + fam.counts.EGT;
|
|
||||||
const famOu = famAvail > 0 ? ((fam.counts.PRD / famAvail) * 100).toFixed(1) : 0;
|
|
||||||
const famOuClass = famOu >= 80 ? 'high' : (famOu >= 50 ? 'medium' : 'low');
|
|
||||||
|
|
||||||
const famExpandBtn = hasEquipment
|
|
||||||
? `<button class="expand-btn ${isFamExpanded ? 'expanded' : ''}" onclick="toggleMatrixRow('${famId}')">▶</button>`
|
|
||||||
: '<span class="expand-placeholder"></span>';
|
|
||||||
|
|
||||||
const famName = fam.name;
|
|
||||||
const escFamName = famName.replace(/'/g, "\\'");
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<tr class="row-level-1 indent-1">
|
|
||||||
<td><span class="row-name">${famExpandBtn}${fam.name}</span></td>
|
|
||||||
<td class="col-total">${fam.counts.total}</td>
|
|
||||||
<td class="col-prd clickable ${fam.counts.PRD === 0 ? 'zero' : ''} ${isSelected(grpName, 'PRD', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="PRD" onclick="filterByMatrixCell('${grpName}', 'PRD', '${escFamName}')">${fam.counts.PRD}</td>
|
|
||||||
<td class="col-sby clickable ${fam.counts.SBY === 0 ? 'zero' : ''} ${isSelected(grpName, 'SBY', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="SBY" onclick="filterByMatrixCell('${grpName}', 'SBY', '${escFamName}')">${fam.counts.SBY}</td>
|
|
||||||
<td class="col-udt clickable ${fam.counts.UDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'UDT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="UDT" onclick="filterByMatrixCell('${grpName}', 'UDT', '${escFamName}')">${fam.counts.UDT}</td>
|
|
||||||
<td class="col-sdt clickable ${fam.counts.SDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'SDT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="SDT" onclick="filterByMatrixCell('${grpName}', 'SDT', '${escFamName}')">${fam.counts.SDT}</td>
|
|
||||||
<td class="col-egt clickable ${fam.counts.EGT === 0 ? 'zero' : ''} ${isSelected(grpName, 'EGT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="EGT" onclick="filterByMatrixCell('${grpName}', 'EGT', '${escFamName}')">${fam.counts.EGT}</td>
|
|
||||||
<td class="col-nst clickable ${fam.counts.NST === 0 ? 'zero' : ''} ${isSelected(grpName, 'NST', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="NST" onclick="filterByMatrixCell('${grpName}', 'NST', '${escFamName}')">${fam.counts.NST}</td>
|
|
||||||
<td class="col-other clickable ${fam.counts.OTHER === 0 ? 'zero' : ''} ${isSelected(grpName, 'OTHER', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="OTHER" onclick="filterByMatrixCell('${grpName}', 'OTHER', '${escFamName}')">${fam.counts.OTHER}</td>
|
|
||||||
<td><span class="ou-badge ${famOuClass}">${famOu}%</span></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Equipment rows (Level 2)
|
|
||||||
if (isFamExpanded) {
|
|
||||||
fam.equipment.forEach(eq => {
|
|
||||||
const status = eq.EQUIPMENTASSETSSTATUS || '--';
|
|
||||||
const statusCat = (eq.STATUS_CATEGORY || 'OTHER').toLowerCase();
|
|
||||||
const resId = eq.RESOURCEID || '';
|
|
||||||
const resName = eq.RESOURCENAME || eq.RESOURCEID || '--';
|
|
||||||
const escResId = resId.replace(/'/g, "\\'");
|
|
||||||
|
|
||||||
// Determine status category key for this equipment
|
|
||||||
let eqStatusKey = 'OTHER';
|
|
||||||
if (['PRD'].includes(status)) eqStatusKey = 'PRD';
|
|
||||||
else if (['SBY'].includes(status)) eqStatusKey = 'SBY';
|
|
||||||
else if (['UDT', 'PM', 'BKD'].includes(status)) eqStatusKey = 'UDT';
|
|
||||||
else if (['SDT'].includes(status)) eqStatusKey = 'SDT';
|
|
||||||
else if (['EGT', 'ENG'].includes(status)) eqStatusKey = 'EGT';
|
|
||||||
else if (['NST', 'OFF'].includes(status)) eqStatusKey = 'NST';
|
|
||||||
|
|
||||||
const isEqSelected = isSelected(grpName, eqStatusKey, famName, resId);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<tr class="row-level-2 indent-2 clickable-row ${isEqSelected ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-res="${resId}" onclick="filterByMatrixCell('${grpName}', '${eqStatusKey}', '${escFamName}', '${escResId}')">
|
|
||||||
<td><span class="row-name"><span class="expand-placeholder"></span>${resName}</span></td>
|
|
||||||
<td>1</td>
|
|
||||||
<td class="col-prd ${status !== 'PRD' ? 'zero' : ''}">${status === 'PRD' ? '●' : '-'}</td>
|
|
||||||
<td class="col-sby ${status !== 'SBY' ? 'zero' : ''}">${status === 'SBY' ? '●' : '-'}</td>
|
|
||||||
<td class="col-udt ${!['UDT', 'PM', 'BKD'].includes(status) ? 'zero' : ''}">${['UDT', 'PM', 'BKD'].includes(status) ? '●' : '-'}</td>
|
|
||||||
<td class="col-sdt ${status !== 'SDT' ? 'zero' : ''}">${status === 'SDT' ? '●' : '-'}</td>
|
|
||||||
<td class="col-egt ${!['EGT', 'ENG'].includes(status) ? 'zero' : ''}">${['EGT', 'ENG'].includes(status) ? '●' : '-'}</td>
|
|
||||||
<td class="col-nst ${!['NST', 'OFF'].includes(status) ? 'zero' : ''}">${['NST', 'OFF'].includes(status) ? '●' : '-'}</td>
|
|
||||||
<td class="col-other">${!['PRD', 'SBY', 'UDT', 'PM', 'BKD', 'SDT', 'EGT', 'ENG', 'NST', 'OFF'].includes(status) ? '●' : '-'}</td>
|
|
||||||
<td><span class="eq-status ${statusCat}">${status}</span></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</tbody></table>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFilter(checkbox, id) {
|
|
||||||
const label = document.getElementById(id);
|
|
||||||
if (checkbox.checked) {
|
|
||||||
label.classList.add('active');
|
|
||||||
} else {
|
|
||||||
label.classList.remove('active');
|
|
||||||
}
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilters() {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
const group = document.getElementById('filterGroup').value;
|
|
||||||
if (group) params.append('workcenter_groups', group);
|
|
||||||
|
|
||||||
if (document.querySelector('#chkProduction input').checked) {
|
|
||||||
params.append('is_production', 'true');
|
|
||||||
}
|
|
||||||
if (document.querySelector('#chkKey input').checked) {
|
|
||||||
params.append('is_key', 'true');
|
|
||||||
}
|
|
||||||
if (document.querySelector('#chkMonitor input').checked) {
|
|
||||||
params.append('is_monitor', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
return params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOptions() {
|
|
||||||
try {
|
|
||||||
const result = await MesApi.get('/api/resource/status/options', { silent: true });
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const select = document.getElementById('filterGroup');
|
|
||||||
workcenterGroups = result.data.workcenter_groups || [];
|
|
||||||
|
|
||||||
workcenterGroups.forEach(group => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = group;
|
|
||||||
opt.textContent = group;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('載入選項失敗:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSummary() {
|
|
||||||
try {
|
|
||||||
const queryString = getFilters();
|
|
||||||
const endpoint = queryString
|
|
||||||
? `/api/resource/status/summary?${queryString}`
|
|
||||||
: '/api/resource/status/summary';
|
|
||||||
const result = await MesApi.get(endpoint, { silent: true });
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const d = result.data;
|
|
||||||
const total = d.total_count || 0;
|
|
||||||
const status = d.by_status || {};
|
|
||||||
|
|
||||||
// Get individual status counts
|
|
||||||
const prd = status.PRD || 0;
|
|
||||||
const sby = status.SBY || 0;
|
|
||||||
const udt = status.UDT || 0;
|
|
||||||
const sdt = status.SDT || 0;
|
|
||||||
const egt = status.EGT || 0;
|
|
||||||
const nst = status.NST || 0;
|
|
||||||
|
|
||||||
// Calculate percentage denominator (includes NST)
|
|
||||||
const totalStatus = prd + sby + udt + sdt + egt + nst;
|
|
||||||
|
|
||||||
// Update OU% and AVAIL%
|
|
||||||
const hasOuPct = d.ou_pct !== null && d.ou_pct !== undefined;
|
|
||||||
const hasAvailPct = d.availability_pct !== null && d.availability_pct !== undefined;
|
|
||||||
document.getElementById('ouPct').textContent = hasOuPct ? `${d.ou_pct}%` : '--';
|
|
||||||
document.getElementById('availabilityPct').textContent = hasAvailPct ? `${d.availability_pct}%` : '--';
|
|
||||||
|
|
||||||
// Update status cards with count and percentage
|
|
||||||
document.getElementById('prdCount').textContent = prd;
|
|
||||||
document.getElementById('prdPct').textContent = totalStatus ? `生產 (${((prd/totalStatus)*100).toFixed(1)}%)` : '生產';
|
|
||||||
|
|
||||||
document.getElementById('sbyCount').textContent = sby;
|
|
||||||
document.getElementById('sbyPct').textContent = totalStatus ? `待機 (${((sby/totalStatus)*100).toFixed(1)}%)` : '待機';
|
|
||||||
|
|
||||||
document.getElementById('udtCount').textContent = udt;
|
|
||||||
document.getElementById('udtPct').textContent = totalStatus ? `非計畫停機 (${((udt/totalStatus)*100).toFixed(1)}%)` : '非計畫停機';
|
|
||||||
|
|
||||||
document.getElementById('sdtCount').textContent = sdt;
|
|
||||||
document.getElementById('sdtPct').textContent = totalStatus ? `計畫停機 (${((sdt/totalStatus)*100).toFixed(1)}%)` : '計畫停機';
|
|
||||||
|
|
||||||
document.getElementById('egtCount').textContent = egt;
|
|
||||||
document.getElementById('egtPct').textContent = totalStatus ? `工程 (${((egt/totalStatus)*100).toFixed(1)}%)` : '工程';
|
|
||||||
|
|
||||||
document.getElementById('nstCount').textContent = nst;
|
|
||||||
document.getElementById('nstPct').textContent = totalStatus ? `未排程 (${((nst/totalStatus)*100).toFixed(1)}%)` : '未排程';
|
|
||||||
|
|
||||||
// Update JOB count (equipment with active maintenance/repair job)
|
|
||||||
const jobCount = d.with_active_job || 0;
|
|
||||||
document.getElementById('jobCount').textContent = jobCount;
|
|
||||||
|
|
||||||
// Update total count
|
|
||||||
document.getElementById('totalCount').textContent = total;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('載入摘要失敗:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMatrix() {
|
|
||||||
// Matrix is now rendered from allEquipment data using hierarchy
|
|
||||||
// This function is called after loadEquipment populates allEquipment
|
|
||||||
renderMatrixHierarchy();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEquipment() {
|
|
||||||
const container = document.getElementById('equipmentContainer');
|
|
||||||
|
|
||||||
// Clear matrix filter when reloading data
|
|
||||||
matrixFilter = null;
|
|
||||||
document.getElementById('matrixFilterIndicator').classList.remove('active');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const queryString = getFilters();
|
|
||||||
const endpoint = queryString
|
|
||||||
? `/api/resource/status?${queryString}`
|
|
||||||
: '/api/resource/status';
|
|
||||||
const result = await MesApi.get(endpoint, { silent: true });
|
|
||||||
|
|
||||||
if (result.success && result.data.length > 0) {
|
|
||||||
allEquipment = result.data;
|
|
||||||
document.getElementById('equipmentCount').textContent = result.count;
|
|
||||||
renderEquipmentList(allEquipment);
|
|
||||||
} else {
|
|
||||||
allEquipment = [];
|
|
||||||
document.getElementById('equipmentCount').textContent = 0;
|
|
||||||
container.innerHTML = '<div class="empty-state">無符合條件的設備</div>';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('載入設備失敗:', e);
|
|
||||||
container.innerHTML = '<div class="empty-state">載入失敗</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Floating Tooltip Functions
|
|
||||||
// ============================================================
|
|
||||||
let currentTooltipData = null;
|
|
||||||
|
|
||||||
function showTooltip(event, type, data) {
|
|
||||||
event.stopPropagation();
|
|
||||||
const tooltip = document.getElementById('floatingTooltip');
|
|
||||||
const titleEl = document.getElementById('tooltipTitle');
|
|
||||||
const contentEl = document.getElementById('tooltipContent');
|
|
||||||
|
|
||||||
// Set content based on type
|
|
||||||
if (type === 'lot') {
|
|
||||||
titleEl.textContent = '在製批次明細';
|
|
||||||
contentEl.innerHTML = renderLotContent(data);
|
|
||||||
} else if (type === 'job') {
|
|
||||||
titleEl.textContent = 'JOB 單詳細資訊';
|
|
||||||
contentEl.innerHTML = renderJobContent(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position the tooltip
|
|
||||||
tooltip.classList.add('show');
|
|
||||||
|
|
||||||
// Get dimensions
|
|
||||||
const rect = tooltip.getBoundingClientRect();
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
// Calculate initial position near the click
|
|
||||||
let x = event.clientX + 10;
|
|
||||||
let y = event.clientY + 10;
|
|
||||||
|
|
||||||
// Adjust if overflowing right
|
|
||||||
if (x + rect.width > viewportWidth - 20) {
|
|
||||||
x = event.clientX - rect.width - 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust if overflowing bottom
|
|
||||||
if (y + rect.height > viewportHeight - 20) {
|
|
||||||
y = viewportHeight - rect.height - 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure not off-screen left or top
|
|
||||||
x = Math.max(10, x);
|
|
||||||
y = Math.max(10, y);
|
|
||||||
|
|
||||||
tooltip.style.left = x + 'px';
|
|
||||||
tooltip.style.top = y + 'px';
|
|
||||||
|
|
||||||
currentTooltipData = { type, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideTooltip() {
|
|
||||||
const tooltip = document.getElementById('floatingTooltip');
|
|
||||||
tooltip.classList.remove('show');
|
|
||||||
currentTooltipData = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close tooltip when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
const tooltip = document.getElementById('floatingTooltip');
|
|
||||||
if (tooltip && !tooltip.contains(e.target) && !e.target.classList.contains('info-trigger')) {
|
|
||||||
hideTooltip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper functions to show specific tooltip types
|
|
||||||
function showLotTooltip(event, resourceId) {
|
|
||||||
const eq = allEquipment.find(e => e.RESOURCEID === resourceId);
|
|
||||||
if (eq && eq.LOT_DETAILS) {
|
|
||||||
showTooltip(event, 'lot', eq.LOT_DETAILS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showJobTooltip(event, resourceId) {
|
|
||||||
const eq = allEquipment.find(e => e.RESOURCEID === resourceId);
|
|
||||||
if (eq && eq.JOBORDER) {
|
|
||||||
showTooltip(event, 'job', eq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLotContent(lotDetails) {
|
|
||||||
if (!lotDetails || lotDetails.length === 0) return '<div style="color: #94a3b8;">無批次資料</div>';
|
|
||||||
|
|
||||||
let html = '<div class="lot-tooltip-content">';
|
|
||||||
lotDetails.forEach(lot => {
|
|
||||||
const trackinTime = lot.LOTTRACKINTIME ? new Date(lot.LOTTRACKINTIME).toLocaleString('zh-TW') : '--';
|
|
||||||
const qty = lot.LOTTRACKINQTY_PCS != null ? lot.LOTTRACKINQTY_PCS.toLocaleString() : '--';
|
|
||||||
html += `
|
|
||||||
<div class="lot-item">
|
|
||||||
<div class="lot-item-header">${lot.RUNCARDLOTID || '--'}</div>
|
|
||||||
<div class="lot-item-row">
|
|
||||||
<div class="lot-item-field"><span class="lot-item-label">數量:</span><span class="lot-item-value">${qty} pcs</span></div>
|
|
||||||
<div class="lot-item-field"><span class="lot-item-label">TrackIn:</span><span class="lot-item-value">${trackinTime}</span></div>
|
|
||||||
<div class="lot-item-field"><span class="lot-item-label">操作員:</span><span class="lot-item-value">${lot.LOTTRACKINEMPLOYEE || '--'}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderJobContent(eq) {
|
|
||||||
const formatDate = (dateStr) => {
|
|
||||||
if (!dateStr) return '--';
|
|
||||||
try {
|
|
||||||
return new Date(dateStr).toLocaleString('zh-TW');
|
|
||||||
} catch {
|
|
||||||
return dateStr;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const field = (label, value, isHighlight = false) => {
|
|
||||||
const valueClass = isHighlight ? 'highlight' : '';
|
|
||||||
return `
|
|
||||||
<div class="job-detail-field">
|
|
||||||
<span class="job-detail-label">${label}</span>
|
|
||||||
<span class="job-detail-value ${valueClass}">${value || '--'}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="job-detail-grid">
|
|
||||||
${field('JOBORDER', eq.JOBORDER, true)}
|
|
||||||
${field('JOBSTATUS', eq.JOBSTATUS, true)}
|
|
||||||
${field('JOBMODEL', eq.JOBMODEL)}
|
|
||||||
${field('JOBSTAGE', eq.JOBSTAGE)}
|
|
||||||
${field('JOBID', eq.JOBID)}
|
|
||||||
${field('建立時間', formatDate(eq.CREATEDATE))}
|
|
||||||
${field('建立人員', eq.CREATEUSERNAME || eq.CREATEUSER)}
|
|
||||||
${field('技術員', eq.TECHNICIANUSERNAME || eq.TECHNICIANUSER)}
|
|
||||||
${field('症狀碼', eq.SYMPTOMCODE)}
|
|
||||||
${field('原因碼', eq.CAUSECODE)}
|
|
||||||
${field('維修碼', eq.REPAIRCODE)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEquipmentList(equipment) {
|
|
||||||
const container = document.getElementById('equipmentContainer');
|
|
||||||
|
|
||||||
if (equipment.length === 0) {
|
|
||||||
container.innerHTML = '<div class="empty-state">無符合條件的設備</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="equipment-grid">';
|
|
||||||
|
|
||||||
equipment.forEach((eq) => {
|
|
||||||
const statusCat = (eq.STATUS_CATEGORY || 'OTHER').toLowerCase();
|
|
||||||
const statusDisplay = getStatusDisplay(eq.EQUIPMENTASSETSSTATUS, eq.STATUS_CATEGORY);
|
|
||||||
const resourceId = eq.RESOURCEID || '';
|
|
||||||
const escapedResourceId = resourceId.replace(/'/g, "\\'");
|
|
||||||
|
|
||||||
// Build LOT info with click trigger
|
|
||||||
let lotHtml = '';
|
|
||||||
if (eq.LOT_COUNT > 0) {
|
|
||||||
lotHtml = `<span class="info-trigger" onclick="showLotTooltip(event, '${escapedResourceId}')" title="點擊查看批次詳情">📦 ${eq.LOT_COUNT} 批</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build JOB info with click trigger
|
|
||||||
let jobHtml = '';
|
|
||||||
if (eq.JOBORDER) {
|
|
||||||
jobHtml = `<span class="info-trigger" onclick="showJobTooltip(event, '${escapedResourceId}')" title="點擊查看JOB詳情">📋 ${eq.JOBORDER}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="equipment-card status-${statusCat}">
|
|
||||||
<div class="eq-header">
|
|
||||||
<div class="eq-name">${eq.RESOURCENAME || eq.RESOURCEID || '--'}</div>
|
|
||||||
<span class="eq-status ${statusCat}">${statusDisplay}</span>
|
|
||||||
</div>
|
|
||||||
<div class="eq-info">
|
|
||||||
<span title="工站">📍 ${eq.WORKCENTERNAME || '--'}</span>
|
|
||||||
<span title="群組">🏭 ${eq.WORKCENTER_GROUP || '--'}</span>
|
|
||||||
<span title="家族">🔧 ${eq.RESOURCEFAMILYNAME || '--'}</span>
|
|
||||||
<span title="區域">🏢 ${eq.LOCATIONNAME || '--'}</span>
|
|
||||||
${lotHtml}
|
|
||||||
${jobHtml}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterByMatrixCell(workcenterGroup, status, family = null, resource = null) {
|
|
||||||
// Toggle off if clicking same cell (exact match including family and resource)
|
|
||||||
if (matrixFilter &&
|
|
||||||
matrixFilter.workcenter_group === workcenterGroup &&
|
|
||||||
matrixFilter.status === status &&
|
|
||||||
matrixFilter.family === family &&
|
|
||||||
matrixFilter.resource === resource) {
|
|
||||||
clearMatrixFilter();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
matrixFilter = {
|
|
||||||
workcenter_group: workcenterGroup,
|
|
||||||
status: status,
|
|
||||||
family: family,
|
|
||||||
resource: resource
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update selected cell highlighting for group and family level cells
|
|
||||||
document.querySelectorAll('.matrix-table td.clickable').forEach(cell => {
|
|
||||||
cell.classList.remove('selected');
|
|
||||||
const cellWg = cell.dataset.wg;
|
|
||||||
const cellStatus = cell.dataset.status;
|
|
||||||
const cellFam = cell.dataset.fam;
|
|
||||||
|
|
||||||
// Match based on level
|
|
||||||
if (cellWg === workcenterGroup && cellStatus === status) {
|
|
||||||
if (family === null && resource === null && !cellFam) {
|
|
||||||
// Group level match
|
|
||||||
cell.classList.add('selected');
|
|
||||||
} else if (family !== null && cellFam === family && resource === null) {
|
|
||||||
// Family level match
|
|
||||||
cell.classList.add('selected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update selected row highlighting for equipment level
|
|
||||||
document.querySelectorAll('.matrix-table tr.clickable-row').forEach(row => {
|
|
||||||
row.classList.remove('selected');
|
|
||||||
if (resource !== null && row.dataset.res === resource) {
|
|
||||||
row.classList.add('selected');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show filter indicator with hierarchical label
|
|
||||||
const statusLabels = {
|
|
||||||
'PRD': '生產中',
|
|
||||||
'SBY': '待機',
|
|
||||||
'UDT': '非計畫停機',
|
|
||||||
'SDT': '計畫停機',
|
|
||||||
'EGT': '工程',
|
|
||||||
'NST': '未排程',
|
|
||||||
'OTHER': '其他'
|
|
||||||
};
|
|
||||||
|
|
||||||
let filterLabel = workcenterGroup;
|
|
||||||
if (family) filterLabel += ` / ${family}`;
|
|
||||||
if (resource) {
|
|
||||||
// Find resource name from allEquipment
|
|
||||||
const eqInfo = allEquipment.find(e => e.RESOURCEID === resource);
|
|
||||||
const resName = eqInfo ? (eqInfo.RESOURCENAME || resource) : resource;
|
|
||||||
filterLabel += ` / ${resName}`;
|
|
||||||
}
|
|
||||||
filterLabel += ` - ${statusLabels[status] || status}`;
|
|
||||||
|
|
||||||
document.getElementById('matrixFilterText').textContent = filterLabel;
|
|
||||||
document.getElementById('matrixFilterIndicator').classList.add('active');
|
|
||||||
|
|
||||||
// Filter and render equipment list
|
|
||||||
// Use same grouping logic as buildMatrixHierarchy
|
|
||||||
const filtered = allEquipment.filter(eq => {
|
|
||||||
// Match workcenter group
|
|
||||||
const eqGroup = eq.WORKCENTER_GROUP || 'UNKNOWN';
|
|
||||||
if (eqGroup !== workcenterGroup) return false;
|
|
||||||
|
|
||||||
// Match family if specified
|
|
||||||
if (family !== null) {
|
|
||||||
const eqFamily = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
|
|
||||||
if (eqFamily !== family) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match resource if specified
|
|
||||||
if (resource !== null) {
|
|
||||||
if (eq.RESOURCEID !== resource) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match status based on EQUIPMENTASSETSSTATUS (same logic as matrix calculation)
|
|
||||||
const eqStatus = eq.EQUIPMENTASSETSSTATUS || '';
|
|
||||||
|
|
||||||
// Map equipment status to matrix status category (same as buildMatrixHierarchy)
|
|
||||||
let eqStatusKey = 'OTHER';
|
|
||||||
if (['PRD'].includes(eqStatus)) eqStatusKey = 'PRD';
|
|
||||||
else if (['SBY'].includes(eqStatus)) eqStatusKey = 'SBY';
|
|
||||||
else if (['UDT', 'PM', 'BKD'].includes(eqStatus)) eqStatusKey = 'UDT';
|
|
||||||
else if (['SDT'].includes(eqStatus)) eqStatusKey = 'SDT';
|
|
||||||
else if (['EGT', 'ENG'].includes(eqStatus)) eqStatusKey = 'EGT';
|
|
||||||
else if (['NST', 'OFF'].includes(eqStatus)) eqStatusKey = 'NST';
|
|
||||||
|
|
||||||
return eqStatusKey === status;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('equipmentCount').textContent = filtered.length;
|
|
||||||
renderEquipmentList(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMatrixFilter() {
|
|
||||||
matrixFilter = null;
|
|
||||||
|
|
||||||
// Remove selected highlighting from cells
|
|
||||||
document.querySelectorAll('.matrix-table td.clickable').forEach(cell => {
|
|
||||||
cell.classList.remove('selected');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove selected highlighting from rows
|
|
||||||
document.querySelectorAll('.matrix-table tr.clickable-row').forEach(row => {
|
|
||||||
row.classList.remove('selected');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide filter indicator
|
|
||||||
document.getElementById('matrixFilterIndicator').classList.remove('active');
|
|
||||||
|
|
||||||
// Show all equipment
|
|
||||||
document.getElementById('equipmentCount').textContent = allEquipment.length;
|
|
||||||
renderEquipmentList(allEquipment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusDisplay(status, category) {
|
|
||||||
const statusMap = {
|
|
||||||
'PRD': '生產中',
|
|
||||||
'SBY': '待機',
|
|
||||||
'UDT': '非計畫停機',
|
|
||||||
'SDT': '計畫停機',
|
|
||||||
'EGT': '工程',
|
|
||||||
'NST': '未排程'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status && statusMap[status]) {
|
|
||||||
return statusMap[status];
|
|
||||||
}
|
|
||||||
|
|
||||||
const catMap = {
|
|
||||||
'PRODUCTIVE': '生產中',
|
|
||||||
'STANDBY': '待機',
|
|
||||||
'DOWN': '停機',
|
|
||||||
'ENGINEERING': '工程',
|
|
||||||
'NOT_SCHEDULED': '未排程',
|
|
||||||
'INACTIVE': '停用'
|
|
||||||
};
|
|
||||||
|
|
||||||
return catMap[category] || status || '--';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkCacheStatus() {
|
|
||||||
try {
|
|
||||||
const data = await MesApi.get('/health', {
|
|
||||||
silent: true,
|
|
||||||
retries: 0,
|
|
||||||
timeout: 15000
|
|
||||||
});
|
|
||||||
|
|
||||||
const dot = document.getElementById('cacheDot');
|
|
||||||
const status = document.getElementById('cacheStatus');
|
|
||||||
const resCache = data.resource_cache || {};
|
|
||||||
const eqCache = data.equipment_status_cache || {};
|
|
||||||
|
|
||||||
// 使用 resource_cache 的數量(過濾後的設備數)
|
|
||||||
if (resCache.enabled && resCache.loaded) {
|
|
||||||
dot.className = 'cache-dot';
|
|
||||||
status.textContent = `快取正常 (${resCache.count} 筆)`;
|
|
||||||
} else if (resCache.enabled) {
|
|
||||||
dot.className = 'cache-dot loading';
|
|
||||||
status.textContent = '快取載入中...';
|
|
||||||
} else {
|
|
||||||
dot.className = 'cache-dot error';
|
|
||||||
status.textContent = '快取未啟用';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 equipment_status_cache 的更新時間(即時狀態更新時間)
|
|
||||||
if (eqCache.updated_at) {
|
|
||||||
document.getElementById('lastUpdate').textContent =
|
|
||||||
`更新: ${new Date(eqCache.updated_at).toLocaleString('zh-TW')}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('cacheDot').className = 'cache-dot error';
|
|
||||||
document.getElementById('cacheStatus').textContent = '無法連線';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
const btn = document.getElementById('btnRefresh');
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// loadSummary can run in parallel
|
|
||||||
// loadEquipment must complete before loadMatrix (matrix uses allEquipment data)
|
|
||||||
await Promise.all([
|
|
||||||
loadSummary(),
|
|
||||||
loadEquipment()
|
|
||||||
]);
|
|
||||||
// Now render the matrix from the loaded equipment data
|
|
||||||
loadMatrix();
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Auto-refresh
|
|
||||||
// ============================================================
|
|
||||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
|
||||||
let refreshTimer = null;
|
|
||||||
|
|
||||||
function startAutoRefresh() {
|
|
||||||
if (refreshTimer) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
}
|
|
||||||
console.log('[Resource Status] Auto-refresh started, interval:', REFRESH_INTERVAL / 1000, 'seconds');
|
|
||||||
refreshTimer = setInterval(() => {
|
|
||||||
if (!document.hidden) {
|
|
||||||
console.log('[Resource Status] Auto-refresh triggered at', new Date().toLocaleTimeString());
|
|
||||||
checkCacheStatus();
|
|
||||||
loadData();
|
|
||||||
} else {
|
|
||||||
console.log('[Resource Status] Auto-refresh skipped (tab hidden)');
|
|
||||||
}
|
|
||||||
}, REFRESH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle page visibility - refresh when tab becomes visible
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (!document.hidden) {
|
|
||||||
console.log('[Resource Status] Tab became visible, refreshing...');
|
|
||||||
checkCacheStatus();
|
|
||||||
loadData();
|
|
||||||
startAutoRefresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
await loadOptions();
|
|
||||||
await checkCacheStatus();
|
|
||||||
await loadData();
|
|
||||||
|
|
||||||
// Start auto-refresh
|
|
||||||
startAutoRefresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.assign(window, {
|
|
||||||
buildMatrixHierarchy,
|
|
||||||
toggleMatrixRow,
|
|
||||||
toggleAllMatrixRows,
|
|
||||||
renderMatrixHierarchy,
|
|
||||||
toggleFilter,
|
|
||||||
getFilters,
|
|
||||||
loadOptions,
|
|
||||||
loadSummary,
|
|
||||||
loadMatrix,
|
|
||||||
loadEquipment,
|
|
||||||
showTooltip,
|
|
||||||
hideTooltip,
|
|
||||||
showLotTooltip,
|
|
||||||
showJobTooltip,
|
|
||||||
renderLotContent,
|
|
||||||
renderJobContent,
|
|
||||||
renderEquipmentList,
|
|
||||||
filterByMatrixCell,
|
|
||||||
clearMatrixFilter,
|
|
||||||
getStatusDisplay,
|
|
||||||
checkCacheStatus,
|
|
||||||
loadData,
|
|
||||||
startAutoRefresh,
|
|
||||||
});
|
|
||||||
|
|||||||
319
frontend/src/resource-status/style.css
Normal file
319
frontend/src/resource-status/style.css
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
.status-header-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 13px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-dot.loading {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-dot.error {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-update {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-panel {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-block label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--resource-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 170px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip input[type='checkbox'] {
|
||||||
|
accent-color: var(--resource-primary);
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.active {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card {
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-left: 4px solid var(--status-other);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: var(--resource-shadow);
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 9px 20px rgba(37, 99, 235, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card.status-prd,
|
||||||
|
.equipment-card.status-productive {
|
||||||
|
border-left-color: var(--status-prd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card.status-sby,
|
||||||
|
.equipment-card.status-standby {
|
||||||
|
border-left-color: var(--status-sby);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card.status-udt,
|
||||||
|
.equipment-card.status-down {
|
||||||
|
border-left-color: var(--status-udt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card.status-sdt {
|
||||||
|
border-left-color: var(--status-sdt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card.status-egt,
|
||||||
|
.equipment-card.status-engineering {
|
||||||
|
border-left-color: var(--status-egt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card.status-nst,
|
||||||
|
.equipment-card.status-not_scheduled {
|
||||||
|
border-left-color: var(--status-nst);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-info {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-info-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-info-item .label {
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-info-item .value {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-triggers {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-trigger {
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-trigger:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1200;
|
||||||
|
width: min(460px, calc(100vw - 20px));
|
||||||
|
max-height: min(520px, calc(100vh - 20px));
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--resource-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.23);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-tooltip-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border-bottom: 1px solid #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-tooltip-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-tooltip-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-tooltip-body {
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-empty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--resource-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lot-item {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lot-item-id {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lot-grid,
|
||||||
|
.job-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(130px, 1fr));
|
||||||
|
gap: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-field-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-field-value.highlight {
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--resource-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.status-header-meta {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-panel {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lot-grid,
|
||||||
|
.job-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'wip-overview': resolve(__dirname, 'src/wip-overview/index.html'),
|
'wip-overview': resolve(__dirname, 'src/wip-overview/index.html'),
|
||||||
'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'),
|
'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'),
|
||||||
'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'),
|
'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'),
|
||||||
'resource-status': resolve(__dirname, 'src/resource-status/main.js'),
|
'resource-status': resolve(__dirname, 'src/resource-status/index.html'),
|
||||||
'resource-history': resolve(__dirname, 'src/resource-history/main.js'),
|
'resource-history': resolve(__dirname, 'src/resource-history/index.html'),
|
||||||
'job-query': resolve(__dirname, 'src/job-query/main.js'),
|
'job-query': resolve(__dirname, 'src/job-query/main.js'),
|
||||||
'excel-query': resolve(__dirname, 'src/excel-query/main.js'),
|
'excel-query': resolve(__dirname, 'src/excel-query/main.js'),
|
||||||
tables: resolve(__dirname, 'src/tables/index.html'),
|
tables: resolve(__dirname, 'src/tables/index.html'),
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-09
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
設備即時概況與設備歷史績效是專案中最後一組使用三層階層樹表的 Jinja2 頁面。兩頁合計 1,697 行 vanilla JS + 3,200 行 Jinja2 模板(含 820 行重複 inline fallback script)。已有 5 頁(QC-GATE、Tables、WIP 三頁)成功遷移至 Vue 3 + Vite 純前端,模式穩定。
|
||||||
|
|
||||||
|
兩頁共享:
|
||||||
|
- 三層階層樹表(workcenter group → family → resource)
|
||||||
|
- E10 狀態碼集(PRD/SBY/UDT/SDT/EGT/NST + 聚合規則 PM→UDT、ENG→EGT、OFF→NST)
|
||||||
|
- OU%/Availability% KPI 計算(`core/compute.js`)
|
||||||
|
- 樹狀展開/收合(`core/table-tree.js`)
|
||||||
|
- 篩選模式(workcenter groups、is_production/is_key/is_monitor)
|
||||||
|
- CSS 變數系統(`:root` 色碼、卡片、表格樣式)
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 兩頁完全脫離 Jinja2 + `_base.html`,改用 `send_from_directory` 靜態服務
|
||||||
|
- 抽取 `resource-shared/` 共用模組,消除兩頁間的重複邏輯
|
||||||
|
- History 頁 ECharts 改用 vue-echarts,與 QC-GATE/WIP Overview 一致
|
||||||
|
- Status 頁複用 `useAutoRefresh` composable
|
||||||
|
- 移除 Jinja2 模板及 inline fallback script
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不修改後端 API 端點或回傳結構
|
||||||
|
- 不新增 npm 依賴(vue-echarts、echarts 已安裝)
|
||||||
|
- 不重構後端 cache 架構
|
||||||
|
- 不增加功能(如 History 頁新增自動刷新)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: 抽取 `resource-shared/` 共用模組
|
||||||
|
|
||||||
|
**選擇**:建立 `frontend/src/resource-shared/` 放置兩頁共用的 CSS、常數、元件。
|
||||||
|
**替代方案**:(a) 各頁獨立 — 大量重複;(b) 放入 `core/` — 太專屬設備頁面,不適合通用模組。
|
||||||
|
**理由**:與 `wip-shared/` 模式一致,語義清晰。
|
||||||
|
|
||||||
|
共用模組內容:
|
||||||
|
- `styles.css`:`:root` 變數、status 顏色、tree table 樣式、KPI 卡片樣式、loading overlay
|
||||||
|
- `constants.js`:`STATUS_DISPLAY_MAP`(6 值中英對照)、`STATUS_AGGREGATION`(PM→UDT 等聚合規則)、`STATUS_COLORS`(6 色碼)、`OU_BADGE_THRESHOLDS`(high≥80/medium≥50/low<50)
|
||||||
|
- `components/HierarchyTable.vue`:三層展開/收合樹表元件,接收 `hierarchy` prop 和 `columns` 定義,兩頁共用
|
||||||
|
|
||||||
|
### D2: HierarchyTable.vue 設計
|
||||||
|
|
||||||
|
**選擇**:單一 `<HierarchyTable>` 元件,透過 `columns` prop 定義欄位、`hierarchy` prop 傳入資料、`@cell-click` 事件處理互動。
|
||||||
|
**替代方案**:(a) 遞迴 TreeNode 元件 — 過度設計,三層固定深度不需遞迴;(b) 各頁獨立表格 — 重複。
|
||||||
|
**理由**:三層結構固定(group → family → resource),用 `v-for` 嵌套即可,不需泛用遞迴。
|
||||||
|
|
||||||
|
### D3: vue-echarts 統一 ECharts 使用方式
|
||||||
|
|
||||||
|
**選擇**:History 頁的 4 個 ECharts 圖表全部改用 `<VChart :option="..." autoresize />`。
|
||||||
|
**替代方案**:直接使用 ECharts API + `onMounted` 手動 init/dispose。
|
||||||
|
**理由**:vue-echarts 已用於 QC-GATE 和 WIP Overview,`autoresize` 解決 iframe 隱藏時 width=0 問題。4 個圖表各自封裝為獨立元件。
|
||||||
|
|
||||||
|
### D4: Status 頁 tooltip 實作
|
||||||
|
|
||||||
|
**選擇**:自訂 `<FloatingTooltip>` 元件 + CSS fixed 定位(移植現有邏輯)。
|
||||||
|
**替代方案**:(a) Floating UI 庫 — 新增依賴;(b) 原生 `title` — 太簡陋。
|
||||||
|
**理由**:現有 tooltip 邏輯已穩定(viewport clamp + 點擊觸發),Vue 化後更乾淨(`<Teleport to="body">` + `v-if`),無需新增依賴。
|
||||||
|
|
||||||
|
### D5: Status 頁自動刷新複用
|
||||||
|
|
||||||
|
**選擇**:直接 import `wip-shared/composables/useAutoRefresh.js`(跨 shared 目錄引用)。
|
||||||
|
**替代方案**:(a) 複製到 resource-shared/ — 重複;(b) 移至 core/ — 改動範圍大。
|
||||||
|
**理由**:composable 無 WIP 特定邏輯,路徑引用 `../../wip-shared/composables/useAutoRefresh.js` 可行。intervalMs 設為 5 分鐘(Status 頁用 5 分鐘而非 WIP 的 10 分鐘)。
|
||||||
|
|
||||||
|
### D6: 多選下拉元件(History 頁)
|
||||||
|
|
||||||
|
**選擇**:自訂 `<MultiSelect>` 元件(移植現有邏輯)。
|
||||||
|
**替代方案**:Element Plus Select — 引入大型 UI 庫。
|
||||||
|
**理由**:現有多選邏輯簡單(checkbox list + click-outside-close + select all/clear),Vue 化後用 `v-model` 綁定即可,無需外部庫。
|
||||||
|
|
||||||
|
### D7: 元件拆分策略
|
||||||
|
|
||||||
|
**resource-status 元件結構**:
|
||||||
|
```
|
||||||
|
App.vue
|
||||||
|
├── StatusHeader.vue (cache 狀態、最後更新時間)
|
||||||
|
├── FilterBar.vue (群組下拉 + 3 checkbox)
|
||||||
|
├── SummaryCards.vue (10 張 KPI 卡片)
|
||||||
|
├── MatrixSection.vue (expand/collapse toolbar + HierarchyTable)
|
||||||
|
├── EquipmentGrid.vue (設備卡片格 + 篩選指示器)
|
||||||
|
│ └── EquipmentCard.vue (單張設備卡片)
|
||||||
|
└── FloatingTooltip.vue (LOT/JOB 詳情 tooltip)
|
||||||
|
```
|
||||||
|
|
||||||
|
**resource-history 元件結構**:
|
||||||
|
```
|
||||||
|
App.vue
|
||||||
|
├── FilterBar.vue (日期 + 粒度 + 多選 + checkbox)
|
||||||
|
│ └── MultiSelect.vue (多選下拉元件)
|
||||||
|
├── KpiCards.vue (9 張 KPI 卡片)
|
||||||
|
├── ChartSection.vue (2×2 圖表格)
|
||||||
|
│ ├── TrendChart.vue (OU%/AVAIL% 趨勢折線)
|
||||||
|
│ ├── StackedChart.vue (E10 狀態堆疊柱狀)
|
||||||
|
│ ├── ComparisonChart.vue (workcenter OU% 橫條)
|
||||||
|
│ └── HeatmapChart.vue (workcenter × 日期 熱圖)
|
||||||
|
├── DetailSection.vue (expand/collapse toolbar + HierarchyTable + CSV 匯出)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[R1] 跨 shared 目錄引用 useAutoRefresh** → 路徑較長但可行,未來若需可移至 `core/`。不在此變更範圍內重構。
|
||||||
|
- **[R2] HierarchyTable 通用性** → 目前設計為兩頁共用,欄位/格式差異透過 props 和 slots 處理。若未來新頁面需要完全不同的樹表,可獨立元件。
|
||||||
|
- **[R3] vue-echarts 4 圖同頁效能** → History 頁同時渲染 4 個圖表,資料量大時可能卡頓。vue-echarts `autoresize` + `computed` option 已足夠,不需 lazy loading。
|
||||||
|
- **[R4] Status 頁 250+ 設備卡片** → 現有實作無虛擬滾動,Vue 化後 `v-for` 渲染相同數量。短期不加虛擬滾動(非 Non-Goal 但不在此範圍),數量級可接受。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
設備即時概況(`/resource-status`,853 行 JS + 1,669 行模板)與設備歷史績效(`/resource-history`,844 行 JS + 1,531 行模板)仍為 Jinja2 + vanilla JS 架構,是最後一組使用三層階層樹表的 Jinja2 頁面。兩頁共享 E10 狀態碼集、OU%/Availability% KPI 計算、workcenter group/family/resource 三層結構、以及相同的篩選模式,適合成對遷移並抽取共用模組。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 將 `/resource-status` 從 Jinja2 + vanilla JS 遷移至 Vue 3 SFC + Vite 純前端架構
|
||||||
|
- 將 `/resource-history` 從 Jinja2 + vanilla JS 遷移至 Vue 3 SFC + Vite 純前端架構
|
||||||
|
- History 頁的 4 個 ECharts 圖表改用 vue-echarts(與 QC-GATE、WIP Overview 一致)
|
||||||
|
- 抽取 `resource-shared/` 共用模組:CSS 基底、狀態常數、HierarchyTable 元件
|
||||||
|
- 兩頁 Vite entry 從 `main.js` 改為 `index.html`,Flask route 改為 `send_from_directory`
|
||||||
|
- 移除兩份 Jinja2 模板(`resource_status.html`、`resource_history.html`)
|
||||||
|
- Status 頁模板內 820 行 inline fallback script 遷移後一併移除
|
||||||
|
- 複用已建立的 `useAutoRefresh` composable(從 `wip-shared/` 移至或引用)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `resource-status-page`: 設備即時概況頁面需求 — 即時矩陣、設備卡片、LOT/JOB tooltip、狀態篩選、5 分鐘自動刷新
|
||||||
|
- `resource-history-page`: 設備歷史績效頁面需求 — 日期區間查詢、4 個 ECharts 圖表、三層階層明細表、多選篩選、CSV 匯出
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `vue-vite-page-architecture`: 新增 Shared CSS import 跨 resource-shared/ 的場景,與 wip-shared/ 模式一致
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **前端**:新增 `frontend/src/resource-shared/`、修改 `frontend/src/resource-status/`、`frontend/src/resource-history/`、`frontend/vite.config.js`、`frontend/package.json`
|
||||||
|
- **後端**:`app.py` 兩條 route 改為 `send_from_directory`,API 端點不變
|
||||||
|
- **移除**:`templates/resource_status.html`、`templates/resource_history.html`
|
||||||
|
- **依賴**:無新增 npm 依賴(vue-echarts、echarts 已安裝)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display KPI summary cards
|
||||||
|
The page SHALL show 9 KPI cards with aggregated performance metrics for the queried period.
|
||||||
|
|
||||||
|
#### Scenario: KPI cards rendering
|
||||||
|
- **WHEN** summary data is loaded from `GET /api/resource/history/summary`
|
||||||
|
- **THEN** 9 cards SHALL display: OU%, AVAIL%, PRD, SBY, UDT, SDT, EGT, NST, Machine Count
|
||||||
|
- **THEN** hour values SHALL format with "K" suffix for large numbers (e.g., 2.5K)
|
||||||
|
- **THEN** percentage values SHALL use `buildResourceKpiFromHours()` from `core/compute.js`
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display trend chart
|
||||||
|
The page SHALL show OU% and Availability% trends over time.
|
||||||
|
|
||||||
|
#### Scenario: Trend chart rendering
|
||||||
|
- **WHEN** summary data is loaded
|
||||||
|
- **THEN** a line chart with area fill SHALL display OU% and AVAIL% time series
|
||||||
|
- **THEN** the chart SHALL use vue-echarts with `autoresize` prop
|
||||||
|
- **THEN** smooth curves with 0.2 opacity area style SHALL render
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display stacked status distribution chart
|
||||||
|
The page SHALL show E10 status hour distribution over time.
|
||||||
|
|
||||||
|
#### Scenario: Stacked bar chart rendering
|
||||||
|
- **WHEN** summary data is loaded
|
||||||
|
- **THEN** a stacked bar chart SHALL display PRD, SBY, UDT, SDT, EGT, NST hours per period
|
||||||
|
- **THEN** each status SHALL use its designated color (PRD=green, SBY=blue, UDT=red, SDT=yellow, EGT=purple, NST=gray)
|
||||||
|
- **THEN** tooltips SHALL show percentages calculated dynamically
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display workcenter comparison chart
|
||||||
|
The page SHALL show top workcenters ranked by OU%.
|
||||||
|
|
||||||
|
#### Scenario: Comparison chart rendering
|
||||||
|
- **WHEN** summary data is loaded
|
||||||
|
- **THEN** a horizontal bar chart SHALL display top 15 workcenters by OU%
|
||||||
|
- **THEN** bars SHALL be color-coded: green (≥80%), yellow (≥50%), red (<50%)
|
||||||
|
- **THEN** data SHALL display in descending OU% order (top to bottom)
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display OU% heatmap
|
||||||
|
The page SHALL show a heatmap of OU% by workcenter and date.
|
||||||
|
|
||||||
|
#### Scenario: Heatmap chart rendering
|
||||||
|
- **WHEN** summary data is loaded
|
||||||
|
- **THEN** a 2D heatmap SHALL display: workcenters (Y-axis) × dates (X-axis)
|
||||||
|
- **THEN** color scale SHALL range from red (low OU%) through yellow to green (high OU%)
|
||||||
|
- **THEN** workcenters SHALL sort by `workcenter_seq` for consistent ordering
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display hierarchical detail table
|
||||||
|
The page SHALL show a three-level expandable table with per-resource performance metrics.
|
||||||
|
|
||||||
|
#### Scenario: Detail table rendering
|
||||||
|
- **WHEN** detail data is loaded from `GET /api/resource/history/detail`
|
||||||
|
- **THEN** a tree table SHALL display with columns: Name, OU%, AVAIL%, PRD, SBY, UDT, SDT, EGT, NST, Count
|
||||||
|
- **THEN** Level 0 rows SHALL show workcenter groups with aggregated metrics
|
||||||
|
- **THEN** Level 1 rows SHALL show resource families with aggregated metrics
|
||||||
|
- **THEN** Level 2 rows SHALL show individual resources
|
||||||
|
|
||||||
|
#### Scenario: Hour and percentage display
|
||||||
|
- **WHEN** detail data renders
|
||||||
|
- **THEN** status columns SHALL display hours with percentage: "10.5h (25%)"
|
||||||
|
- **THEN** KPI values SHALL be computed using `buildResourceKpiFromHours()` from `core/compute.js`
|
||||||
|
|
||||||
|
#### Scenario: Tree expand and collapse
|
||||||
|
- **WHEN** user clicks the expand button on a row
|
||||||
|
- **THEN** child rows SHALL toggle visibility
|
||||||
|
- **WHEN** user clicks "Expand All" or "Collapse All"
|
||||||
|
- **THEN** all rows SHALL expand or collapse accordingly
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL support date range and granularity selection
|
||||||
|
The page SHALL allow users to specify time range and aggregation granularity.
|
||||||
|
|
||||||
|
#### Scenario: Date range selection
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** date inputs SHALL default to last 7 days (yesterday minus 6 days)
|
||||||
|
- **THEN** date range SHALL NOT exceed 730 days (2 years)
|
||||||
|
|
||||||
|
#### Scenario: Granularity buttons
|
||||||
|
- **WHEN** user clicks a granularity button (日/週/月/年)
|
||||||
|
- **THEN** the active button SHALL highlight
|
||||||
|
- **THEN** the next query SHALL use the selected granularity (day/week/month/year)
|
||||||
|
|
||||||
|
#### Scenario: Query execution
|
||||||
|
- **WHEN** user clicks the query button
|
||||||
|
- **THEN** summary and detail APIs SHALL be called in parallel
|
||||||
|
- **THEN** all 4 charts, KPI cards, and detail table SHALL update with results
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL support multi-select filtering
|
||||||
|
The page SHALL provide multi-select dropdown filters for workcenter groups and families.
|
||||||
|
|
||||||
|
#### Scenario: Multi-select dropdown
|
||||||
|
- **WHEN** user clicks a multi-select dropdown trigger
|
||||||
|
- **THEN** a dropdown SHALL display with checkboxes for each option
|
||||||
|
- **THEN** "Select All" and "Clear All" buttons SHALL be available
|
||||||
|
- **THEN** clicking outside the dropdown SHALL close it
|
||||||
|
|
||||||
|
#### Scenario: Filter options loading
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** workcenter groups and families SHALL load from `GET /api/resource/history/options`
|
||||||
|
|
||||||
|
#### Scenario: Equipment type checkboxes
|
||||||
|
- **WHEN** user toggles a checkbox (生產設備, 重點設備, 監控設備)
|
||||||
|
- **THEN** the next query SHALL include the corresponding filter parameter
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL support CSV export
|
||||||
|
The page SHALL allow users to export the current query results as CSV.
|
||||||
|
|
||||||
|
#### Scenario: CSV export
|
||||||
|
- **WHEN** user clicks the "匯出 CSV" button
|
||||||
|
- **THEN** the browser SHALL download a CSV file from `GET /api/resource/history/export` with current filters
|
||||||
|
- **THEN** the filename SHALL be `resource_history_{start_date}_to_{end_date}.csv`
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL handle loading and error states
|
||||||
|
The page SHALL display appropriate feedback during API calls and on errors.
|
||||||
|
|
||||||
|
#### Scenario: Query loading state
|
||||||
|
- **WHEN** a query is executing
|
||||||
|
- **THEN** the query button SHALL be disabled
|
||||||
|
- **THEN** a loading indicator SHALL display
|
||||||
|
|
||||||
|
#### Scenario: API error handling
|
||||||
|
- **WHEN** an API call fails
|
||||||
|
- **THEN** a toast notification SHALL display the error message
|
||||||
|
- **THEN** the page SHALL NOT crash or become unresponsive
|
||||||
|
|
||||||
|
#### Scenario: No data placeholder
|
||||||
|
- **WHEN** query returns empty results
|
||||||
|
- **THEN** charts and table SHALL display "No data" placeholders
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL display summary KPI cards
|
||||||
|
The page SHALL show 10 summary cards with aggregated equipment status statistics.
|
||||||
|
|
||||||
|
#### Scenario: Summary cards rendering
|
||||||
|
- **WHEN** equipment data is loaded from `GET /api/resource/status/summary`
|
||||||
|
- **THEN** 10 cards SHALL display: Total, PRD, SBY, UDT, SDT, EGT, NST, OTHER, OU%, Availability%
|
||||||
|
- **THEN** each status card SHALL show count and percentage
|
||||||
|
- **THEN** OU% card SHALL use color coding: green (≥80%), yellow (≥50%), red (<50%)
|
||||||
|
|
||||||
|
#### Scenario: Status card click filters equipment
|
||||||
|
- **WHEN** user clicks a status card (PRD, SBY, UDT, SDT, EGT, NST)
|
||||||
|
- **THEN** the equipment grid SHALL filter to show only equipment in that status
|
||||||
|
- **THEN** the clicked card SHALL show an active visual state
|
||||||
|
- **THEN** clicking the same card again SHALL remove the filter
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL display hierarchical matrix table
|
||||||
|
The page SHALL show a three-level expandable matrix of workcenter group, family, and resource with status columns.
|
||||||
|
|
||||||
|
#### Scenario: Matrix table rendering
|
||||||
|
- **WHEN** equipment data is loaded from `GET /api/resource/status`
|
||||||
|
- **THEN** a matrix table SHALL display with columns: Name, Total, PRD, SBY, UDT, SDT, EGT, NST, OTHER, OU%
|
||||||
|
- **THEN** Level 0 rows SHALL show workcenter groups with aggregated counts
|
||||||
|
- **THEN** Level 1 rows SHALL show resource families with aggregated counts
|
||||||
|
- **THEN** Level 2 rows SHALL show individual equipment with status indicator
|
||||||
|
|
||||||
|
#### Scenario: Status code aggregation
|
||||||
|
- **WHEN** equipment has status PM or BKD
|
||||||
|
- **THEN** the status SHALL be aggregated under UDT column
|
||||||
|
- **WHEN** equipment has status ENG
|
||||||
|
- **THEN** the status SHALL be aggregated under EGT column
|
||||||
|
- **WHEN** equipment has status OFF
|
||||||
|
- **THEN** the status SHALL be aggregated under NST column
|
||||||
|
|
||||||
|
#### Scenario: Tree expand and collapse
|
||||||
|
- **WHEN** user clicks the expand button on a Level 0 row
|
||||||
|
- **THEN** Level 1 rows (families) for that group SHALL toggle visibility
|
||||||
|
- **WHEN** user clicks the expand button on a Level 1 row
|
||||||
|
- **THEN** Level 2 rows (equipment) for that family SHALL toggle visibility
|
||||||
|
- **WHEN** user clicks "Expand All" or "Collapse All" in the toolbar
|
||||||
|
- **THEN** all tree rows SHALL expand or collapse accordingly
|
||||||
|
|
||||||
|
#### Scenario: Matrix cell click filters equipment
|
||||||
|
- **WHEN** user clicks a status count cell in the matrix (e.g., PRD count for a workcenter group)
|
||||||
|
- **THEN** the equipment grid SHALL filter to that workcenter group and status
|
||||||
|
- **THEN** the clicked cell SHALL show a selected visual state
|
||||||
|
- **THEN** a filter indicator banner SHALL display showing active filters
|
||||||
|
- **THEN** clicking the same cell again SHALL remove the filter
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL display equipment card grid
|
||||||
|
The page SHALL show filterable equipment cards with status information.
|
||||||
|
|
||||||
|
#### Scenario: Equipment card rendering
|
||||||
|
- **WHEN** equipment data is loaded
|
||||||
|
- **THEN** cards SHALL display in a responsive grid (auto-fill, min 280px)
|
||||||
|
- **THEN** each card SHALL show: resource name, status badge, workcenter, group, family, location
|
||||||
|
- **THEN** each card SHALL have a colored left border matching its status category
|
||||||
|
|
||||||
|
#### Scenario: LOT information tooltip
|
||||||
|
- **WHEN** user clicks the LOT count indicator on an equipment card
|
||||||
|
- **THEN** a floating tooltip SHALL display with LOT details: LOTID, QTY, track-in time, employee
|
||||||
|
- **THEN** the tooltip SHALL be positioned within the viewport (clamp to edges)
|
||||||
|
- **THEN** clicking outside the tooltip SHALL close it
|
||||||
|
|
||||||
|
#### Scenario: JOB information tooltip
|
||||||
|
- **WHEN** user clicks the JOB indicator on an equipment card
|
||||||
|
- **THEN** a floating tooltip SHALL display with JOB details: order, status, model, stage, technician, symptom/cause/repair codes
|
||||||
|
- **THEN** the tooltip SHALL use the same positioning logic as the LOT tooltip
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL support workcenter and equipment type filtering
|
||||||
|
The page SHALL provide filter controls to narrow the displayed equipment.
|
||||||
|
|
||||||
|
#### Scenario: Workcenter group filter
|
||||||
|
- **WHEN** user selects a workcenter group from the dropdown
|
||||||
|
- **THEN** all data (summary, matrix, equipment) SHALL reload filtered to that group
|
||||||
|
- **THEN** the dropdown options SHALL be loaded from `GET /api/resource/status/options`
|
||||||
|
|
||||||
|
#### Scenario: Equipment type checkboxes
|
||||||
|
- **WHEN** user toggles a checkbox (生產設備, 重點設備, 監控設備)
|
||||||
|
- **THEN** all data SHALL reload with the corresponding filter (is_production, is_key, is_monitor)
|
||||||
|
- **THEN** multiple checkboxes can be active simultaneously
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL display cache status
|
||||||
|
The page SHALL show the real-time cache health and last update time.
|
||||||
|
|
||||||
|
#### Scenario: Cache status indicator
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** the page SHALL call `GET /health` to check cache status
|
||||||
|
- **THEN** a green dot SHALL display when cache is loaded and enabled
|
||||||
|
- **THEN** a yellow dot SHALL display when cache is loading
|
||||||
|
- **THEN** a red dot SHALL display when cache is not enabled
|
||||||
|
- **THEN** the last update timestamp SHALL display from equipment status cache metadata
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL auto-refresh and handle request cancellation
|
||||||
|
The page SHALL automatically refresh data and prevent stale request pile-up.
|
||||||
|
|
||||||
|
#### Scenario: Auto-refresh interval
|
||||||
|
- **WHEN** the page is loaded
|
||||||
|
- **THEN** data SHALL auto-refresh every 5 minutes
|
||||||
|
- **THEN** auto-refresh SHALL be skipped when the tab is hidden
|
||||||
|
|
||||||
|
#### Scenario: Visibility change refresh
|
||||||
|
- **WHEN** the tab becomes visible after being hidden
|
||||||
|
- **THEN** data SHALL refresh immediately
|
||||||
|
|
||||||
|
#### Scenario: Manual refresh
|
||||||
|
- **WHEN** user clicks the refresh button
|
||||||
|
- **THEN** data SHALL reload and the auto-refresh timer SHALL reset
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL handle loading and error states
|
||||||
|
The page SHALL display appropriate feedback during API calls and on errors.
|
||||||
|
|
||||||
|
#### Scenario: Initial loading overlay
|
||||||
|
- **WHEN** the page first loads
|
||||||
|
- **THEN** a loading overlay SHALL display until all data is loaded
|
||||||
|
|
||||||
|
#### Scenario: API error handling
|
||||||
|
- **WHEN** an API call fails
|
||||||
|
- **THEN** the affected section SHALL display an error message
|
||||||
|
- **THEN** the page SHALL NOT crash or become unresponsive
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Vite config SHALL support Vue SFC and HTML entry points
|
||||||
|
The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries.
|
||||||
|
|
||||||
|
#### Scenario: Vue plugin coexistence
|
||||||
|
- **WHEN** `vite build` is executed
|
||||||
|
- **THEN** Vue SFC (`.vue` files) SHALL be compiled by `@vitejs/plugin-vue`
|
||||||
|
- **THEN** existing vanilla JS entry points SHALL continue to build without modification
|
||||||
|
|
||||||
|
#### Scenario: HTML entry point
|
||||||
|
- **WHEN** a page uses an HTML file as its Vite entry point
|
||||||
|
- **THEN** Vite SHALL process the HTML and its referenced JS/CSS into `static/dist/`
|
||||||
|
- **THEN** the output SHALL include `<page-name>.html`, `<page-name>.js`, and `<page-name>.css`
|
||||||
|
|
||||||
|
#### Scenario: Chunk splitting
|
||||||
|
- **WHEN** Vite builds the project
|
||||||
|
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
|
||||||
|
- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk
|
||||||
|
- **THEN** chunk splitting SHALL NOT affect existing page bundles
|
||||||
|
|
||||||
|
#### Scenario: Migrated page entry replacement
|
||||||
|
- **WHEN** a vanilla JS page is migrated to Vue 3
|
||||||
|
- **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/resource-status/main.js` → `src/resource-status/index.html`)
|
||||||
|
- **THEN** the original JS entry SHALL be replaced, not kept alongside
|
||||||
|
|
||||||
|
#### Scenario: Shared CSS import across migrated pages
|
||||||
|
- **WHEN** multiple migrated pages import a shared CSS module (e.g., `resource-shared/styles.css`)
|
||||||
|
- **THEN** Vite SHALL bundle the shared CSS into each page's output CSS
|
||||||
|
- **THEN** shared CSS SHALL NOT create a separate shared chunk that requires additional HTTP requests
|
||||||
|
|
||||||
|
#### Scenario: Shared composable import across module boundaries
|
||||||
|
- **WHEN** a migrated page imports a composable from another shared module (e.g., `resource-status` imports `useAutoRefresh` from `wip-shared/`)
|
||||||
|
- **THEN** the composable SHALL be bundled into the importing page's JS output
|
||||||
|
- **THEN** cross-module imports SHALL NOT create unexpected shared chunks
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## 1. resource-shared 共用模組
|
||||||
|
|
||||||
|
- [x] 1.1 建立 `frontend/src/resource-shared/styles.css` — 從兩頁 Jinja2 模板抽取共用 CSS:`:root` 變數、status 顏色類別(`.col-prd`/`.col-sby`/...)、tree table 樣式(`.row-level-0`/`.row-level-1`/`.row-level-2`、`.indent-1`/`.indent-2`、`.expand-btn`)、KPI 卡片樣式、OU badge 色碼(`.ou-badge.high/medium/low`)、loading overlay、filter indicator
|
||||||
|
- [x] 1.2 建立 `frontend/src/resource-shared/constants.js` — STATUS_DISPLAY_MAP(PRD→生產中 等 6+值)、STATUS_AGGREGATION(PM/BKD→UDT、ENG→EGT、OFF→NST)、STATUS_COLORS(PRD=#22c55e 等 6 色)、OU_BADGE_THRESHOLDS(high≥80、medium≥50、low<50)、MATRIX_STATUS_COLUMNS 順序定義
|
||||||
|
- [x] 1.3 建立 `frontend/src/resource-shared/components/HierarchyTable.vue` — 三層展開/收合樹表元件,props: `hierarchy`(三層資料)、`columns`(欄位定義陣列)、`expandedState`(reactive 物件);events: `@cell-click`、`@toggle-row`、`@toggle-all`;支援 Level 0/1/2 行樣式和縮排
|
||||||
|
|
||||||
|
## 2. resource-status Vue 3 遷移
|
||||||
|
|
||||||
|
- [x] 2.1 建立 `frontend/src/resource-status/index.html` — HTML entry point,引用 main.js,設定 `<title>設備即時概況</title>`
|
||||||
|
- [x] 2.2 建立 `frontend/src/resource-status/App.vue` — 頂層元件,整合所有子元件,管理全域狀態(allEquipment、matrixFilter、hierarchyState),呼叫 loadData/loadOptions/loadSummary
|
||||||
|
- [x] 2.3 建立 `frontend/src/resource-status/components/StatusHeader.vue` — cache 狀態指示(green/yellow/red dot)、最後更新時間、手動刷新按鈕
|
||||||
|
- [x] 2.4 建立 `frontend/src/resource-status/components/FilterBar.vue` — workcenter group 下拉(GET /api/resource/status/options)+ 3 個 checkbox(生產設備/重點設備/監控設備)
|
||||||
|
- [x] 2.5 建立 `frontend/src/resource-status/components/SummaryCards.vue` — 10 張 KPI 卡片(Total/PRD/SBY/UDT/SDT/EGT/NST/OTHER/OU%/AVAIL%),含 click 篩選、active 狀態、百分比顯示
|
||||||
|
- [x] 2.6 建立 `frontend/src/resource-status/components/MatrixSection.vue` — 使用 resource-shared/HierarchyTable,buildMatrixHierarchy 邏輯(3 層聚合 + 狀態歸類),toolbar(expand/collapse all),cell click 篩選設備
|
||||||
|
- [x] 2.7 建立 `frontend/src/resource-status/components/EquipmentGrid.vue` + `EquipmentCard.vue` — 設備卡片格(auto-fill grid, min 280px),每卡顯示 resource name/status badge/workcenter/group/family/location/LOT count/JOB indicator,狀態色邊框,篩選指示器
|
||||||
|
- [x] 2.8 建立 `frontend/src/resource-status/components/FloatingTooltip.vue` — `<Teleport to="body">` + `v-if`,LOT 詳情(LOTID/QTY/track-in time/employee)和 JOB 詳情(order/status/model/technician/codes),viewport clamp 定位邏輯
|
||||||
|
- [x] 2.9 整合 useAutoRefresh — import `wip-shared/composables/useAutoRefresh.js`,intervalMs 設為 5 分鐘(5 * 60 * 1000),接入 loadData 作為 onRefresh
|
||||||
|
|
||||||
|
## 3. resource-history Vue 3 遷移
|
||||||
|
|
||||||
|
- [x] 3.1 建立 `frontend/src/resource-history/index.html` — HTML entry point,設定 `<title>設備歷史績效</title>`
|
||||||
|
- [x] 3.2 建立 `frontend/src/resource-history/App.vue` — 頂層元件,管理 summaryData/detailData/hierarchyState/filters 狀態,executeQuery 整合 parallel API calls
|
||||||
|
- [x] 3.3 建立 `frontend/src/resource-history/components/FilterBar.vue` — 日期區間(預設 last 7 days)+ 粒度按鈕(日/週/月/年)+ 查詢按鈕
|
||||||
|
- [x] 3.4 建立 `frontend/src/resource-history/components/MultiSelect.vue` — 多選下拉元件(checkbox list + click-outside-close + select all/clear),v-model 綁定 selectedItems 陣列,供 workcenter groups 和 families 使用
|
||||||
|
- [x] 3.5 建立 `frontend/src/resource-history/components/KpiCards.vue` — 9 張 KPI 卡片(OU%/AVAIL%/PRD/SBY/UDT/SDT/EGT/NST/Machine Count),使用 buildResourceKpiFromHours() 計算,大數值用 K 格式
|
||||||
|
- [x] 3.6 建立 `frontend/src/resource-history/components/TrendChart.vue` — vue-echarts 折線圖(OU%+AVAIL% 雙線,smooth area fill 0.2 opacity),`<VChart :option="chartOption" autoresize />`
|
||||||
|
- [x] 3.7 建立 `frontend/src/resource-history/components/StackedChart.vue` — vue-echarts 堆疊柱狀圖(6 狀態 hours per period),使用 resource-shared STATUS_COLORS
|
||||||
|
- [x] 3.8 建立 `frontend/src/resource-history/components/ComparisonChart.vue` — vue-echarts 橫向柱狀圖(top 15 workcenters by OU%),色碼 green/yellow/red(同 OU badge 閾值)
|
||||||
|
- [x] 3.9 建立 `frontend/src/resource-history/components/HeatmapChart.vue` — vue-echarts 2D 熱圖(workcenters × dates),visualMap red→yellow→green,workcenter_seq 排序
|
||||||
|
- [x] 3.10 建立 `frontend/src/resource-history/components/DetailSection.vue` — 使用 resource-shared/HierarchyTable,buildHierarchy 邏輯(3 層聚合 + hours 計算),toolbar(expand/collapse all + CSV export 按鈕)
|
||||||
|
- [x] 3.11 實作 CSV 匯出 — 點擊按鈕建立臨時 `<a>` 導向 `/api/resource/history/export?...` 下載
|
||||||
|
|
||||||
|
## 4. Vite 建置與 Flask 路由
|
||||||
|
|
||||||
|
- [x] 4.1 更新 `frontend/vite.config.js` — resource-status 和 resource-history entry 從 `main.js` 改為 `index.html`
|
||||||
|
- [x] 4.2 更新 `frontend/package.json` build script — 新增 resource-status.html 和 resource-history.html 的 copy 指令
|
||||||
|
- [x] 4.3 更新 `src/mes_dashboard/app.py` — `/resource` route 改為 `send_from_directory(dist_dir, 'resource-status.html')`,`/resource-history` route 改為 `send_from_directory(dist_dir, 'resource-history.html')`
|
||||||
|
|
||||||
|
## 5. 清理與驗證
|
||||||
|
|
||||||
|
- [x] 5.1 刪除 Jinja2 模板 `templates/resource_status.html` 和 `templates/resource_history.html`
|
||||||
|
- [x] 5.2 刪除 resource-status/main.js 和 resource-history/main.js 中的舊 vanilla JS 程式碼(替換為 Vue 3 的 createApp 入口)
|
||||||
|
- [x] 5.3 執行 `npm run build` 確認建置成功,確認 `static/dist/` 產出 resource-status.html/js/css 和 resource-history.html/js/css
|
||||||
|
- [x] 5.4 驗證兩頁在 portal iframe 中正常載入,CSP frame-ancestors 'self' 允許嵌入
|
||||||
127
openspec/specs/resource-history-page/spec.md
Normal file
127
openspec/specs/resource-history-page/spec.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display KPI summary cards
|
||||||
|
The page SHALL show 9 KPI cards with aggregated performance metrics for the queried period.
|
||||||
|
|
||||||
|
#### Scenario: KPI cards rendering
|
||||||
|
- **WHEN** summary data is loaded from `GET /api/resource/history/summary`
|
||||||
|
- **THEN** 9 cards SHALL display: OU%, AVAIL%, PRD, SBY, UDT, SDT, EGT, NST, Machine Count
|
||||||
|
- **THEN** hour values SHALL format with "K" suffix for large numbers (e.g., 2.5K)
|
||||||
|
- **THEN** percentage values SHALL use `buildResourceKpiFromHours()` from `core/compute.js`
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display trend chart
|
||||||
|
The page SHALL show OU% and Availability% trends over time.
|
||||||
|
|
||||||
|
#### Scenario: Trend chart rendering
|
||||||
|
- **WHEN** summary data is loaded
|
||||||
|
- **THEN** a line chart with area fill SHALL display OU% and AVAIL% time series
|
||||||
|
- **THEN** the chart SHALL use vue-echarts with `autoresize` prop
|
||||||
|
- **THEN** smooth curves with 0.2 opacity area style SHALL render
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display stacked status distribution chart
|
||||||
|
The page SHALL show E10 status hour distribution over time.
|
||||||
|
|
||||||
|
#### Scenario: Stacked bar chart rendering
|
||||||
|
- **WHEN** summary data is loaded
|
||||||
|
- **THEN** a stacked bar chart SHALL display PRD, SBY, UDT, SDT, EGT, NST hours per period
|
||||||
|
- **THEN** each status SHALL use its designated color (PRD=green, SBY=blue, UDT=red, SDT=yellow, EGT=purple, NST=gray)
|
||||||
|
- **THEN** tooltips SHALL show percentages calculated dynamically
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display workcenter comparison chart
|
||||||
|
The page SHALL show top workcenters ranked by OU%.
|
||||||
|
|
||||||
|
#### Scenario: Comparison chart rendering
|
||||||
|
- **WHEN** summary data is loaded
|
||||||
|
- **THEN** a horizontal bar chart SHALL display top 15 workcenters by OU%
|
||||||
|
- **THEN** bars SHALL be color-coded: green (≥80%), yellow (≥50%), red (<50%)
|
||||||
|
- **THEN** data SHALL display in descending OU% order (top to bottom)
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display OU% heatmap
|
||||||
|
The page SHALL show a heatmap of OU% by workcenter and date.
|
||||||
|
|
||||||
|
#### Scenario: Heatmap chart rendering
|
||||||
|
- **WHEN** summary data is loaded
|
||||||
|
- **THEN** a 2D heatmap SHALL display: workcenters (Y-axis) × dates (X-axis)
|
||||||
|
- **THEN** color scale SHALL range from red (low OU%) through yellow to green (high OU%)
|
||||||
|
- **THEN** workcenters SHALL sort by `workcenter_seq` for consistent ordering
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL display hierarchical detail table
|
||||||
|
The page SHALL show a three-level expandable table with per-resource performance metrics.
|
||||||
|
|
||||||
|
#### Scenario: Detail table rendering
|
||||||
|
- **WHEN** detail data is loaded from `GET /api/resource/history/detail`
|
||||||
|
- **THEN** a tree table SHALL display with columns: Name, OU%, AVAIL%, PRD, SBY, UDT, SDT, EGT, NST, Count
|
||||||
|
- **THEN** Level 0 rows SHALL show workcenter groups with aggregated metrics
|
||||||
|
- **THEN** Level 1 rows SHALL show resource families with aggregated metrics
|
||||||
|
- **THEN** Level 2 rows SHALL show individual resources
|
||||||
|
|
||||||
|
#### Scenario: Hour and percentage display
|
||||||
|
- **WHEN** detail data renders
|
||||||
|
- **THEN** status columns SHALL display hours with percentage: "10.5h (25%)"
|
||||||
|
- **THEN** KPI values SHALL be computed using `buildResourceKpiFromHours()` from `core/compute.js`
|
||||||
|
|
||||||
|
#### Scenario: Tree expand and collapse
|
||||||
|
- **WHEN** user clicks the expand button on a row
|
||||||
|
- **THEN** child rows SHALL toggle visibility
|
||||||
|
- **WHEN** user clicks "Expand All" or "Collapse All"
|
||||||
|
- **THEN** all rows SHALL expand or collapse accordingly
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL support date range and granularity selection
|
||||||
|
The page SHALL allow users to specify time range and aggregation granularity.
|
||||||
|
|
||||||
|
#### Scenario: Date range selection
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** date inputs SHALL default to last 7 days (yesterday minus 6 days)
|
||||||
|
- **THEN** date range SHALL NOT exceed 730 days (2 years)
|
||||||
|
|
||||||
|
#### Scenario: Granularity buttons
|
||||||
|
- **WHEN** user clicks a granularity button (日/週/月/年)
|
||||||
|
- **THEN** the active button SHALL highlight
|
||||||
|
- **THEN** the next query SHALL use the selected granularity (day/week/month/year)
|
||||||
|
|
||||||
|
#### Scenario: Query execution
|
||||||
|
- **WHEN** user clicks the query button
|
||||||
|
- **THEN** summary and detail APIs SHALL be called in parallel
|
||||||
|
- **THEN** all 4 charts, KPI cards, and detail table SHALL update with results
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL support multi-select filtering
|
||||||
|
The page SHALL provide multi-select dropdown filters for workcenter groups and families.
|
||||||
|
|
||||||
|
#### Scenario: Multi-select dropdown
|
||||||
|
- **WHEN** user clicks a multi-select dropdown trigger
|
||||||
|
- **THEN** a dropdown SHALL display with checkboxes for each option
|
||||||
|
- **THEN** "Select All" and "Clear All" buttons SHALL be available
|
||||||
|
- **THEN** clicking outside the dropdown SHALL close it
|
||||||
|
|
||||||
|
#### Scenario: Filter options loading
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** workcenter groups and families SHALL load from `GET /api/resource/history/options`
|
||||||
|
|
||||||
|
#### Scenario: Equipment type checkboxes
|
||||||
|
- **WHEN** user toggles a checkbox (生產設備, 重點設備, 監控設備)
|
||||||
|
- **THEN** the next query SHALL include the corresponding filter parameter
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL support CSV export
|
||||||
|
The page SHALL allow users to export the current query results as CSV.
|
||||||
|
|
||||||
|
#### Scenario: CSV export
|
||||||
|
- **WHEN** user clicks the "匯出 CSV" button
|
||||||
|
- **THEN** the browser SHALL download a CSV file from `GET /api/resource/history/export` with current filters
|
||||||
|
- **THEN** the filename SHALL be `resource_history_{start_date}_to_{end_date}.csv`
|
||||||
|
|
||||||
|
### Requirement: Resource History page SHALL handle loading and error states
|
||||||
|
The page SHALL display appropriate feedback during API calls and on errors.
|
||||||
|
|
||||||
|
#### Scenario: Query loading state
|
||||||
|
- **WHEN** a query is executing
|
||||||
|
- **THEN** the query button SHALL be disabled
|
||||||
|
- **THEN** a loading indicator SHALL display
|
||||||
|
|
||||||
|
#### Scenario: API error handling
|
||||||
|
- **WHEN** an API call fails
|
||||||
|
- **THEN** a toast notification SHALL display the error message
|
||||||
|
- **THEN** the page SHALL NOT crash or become unresponsive
|
||||||
|
|
||||||
|
#### Scenario: No data placeholder
|
||||||
|
- **WHEN** query returns empty results
|
||||||
|
- **THEN** charts and table SHALL display "No data" placeholders
|
||||||
121
openspec/specs/resource-status-page/spec.md
Normal file
121
openspec/specs/resource-status-page/spec.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL display summary KPI cards
|
||||||
|
The page SHALL show 10 summary cards with aggregated equipment status statistics.
|
||||||
|
|
||||||
|
#### Scenario: Summary cards rendering
|
||||||
|
- **WHEN** equipment data is loaded from `GET /api/resource/status/summary`
|
||||||
|
- **THEN** 10 cards SHALL display: Total, PRD, SBY, UDT, SDT, EGT, NST, OTHER, OU%, Availability%
|
||||||
|
- **THEN** each status card SHALL show count and percentage
|
||||||
|
- **THEN** OU% card SHALL use color coding: green (≥80%), yellow (≥50%), red (<50%)
|
||||||
|
|
||||||
|
#### Scenario: Status card click filters equipment
|
||||||
|
- **WHEN** user clicks a status card (PRD, SBY, UDT, SDT, EGT, NST)
|
||||||
|
- **THEN** the equipment grid SHALL filter to show only equipment in that status
|
||||||
|
- **THEN** the clicked card SHALL show an active visual state
|
||||||
|
- **THEN** clicking the same card again SHALL remove the filter
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL display hierarchical matrix table
|
||||||
|
The page SHALL show a three-level expandable matrix of workcenter group, family, and resource with status columns.
|
||||||
|
|
||||||
|
#### Scenario: Matrix table rendering
|
||||||
|
- **WHEN** equipment data is loaded from `GET /api/resource/status`
|
||||||
|
- **THEN** a matrix table SHALL display with columns: Name, Total, PRD, SBY, UDT, SDT, EGT, NST, OTHER, OU%
|
||||||
|
- **THEN** Level 0 rows SHALL show workcenter groups with aggregated counts
|
||||||
|
- **THEN** Level 1 rows SHALL show resource families with aggregated counts
|
||||||
|
- **THEN** Level 2 rows SHALL show individual equipment with status indicator
|
||||||
|
|
||||||
|
#### Scenario: Status code aggregation
|
||||||
|
- **WHEN** equipment has status PM or BKD
|
||||||
|
- **THEN** the status SHALL be aggregated under UDT column
|
||||||
|
- **WHEN** equipment has status ENG
|
||||||
|
- **THEN** the status SHALL be aggregated under EGT column
|
||||||
|
- **WHEN** equipment has status OFF
|
||||||
|
- **THEN** the status SHALL be aggregated under NST column
|
||||||
|
|
||||||
|
#### Scenario: Tree expand and collapse
|
||||||
|
- **WHEN** user clicks the expand button on a Level 0 row
|
||||||
|
- **THEN** Level 1 rows (families) for that group SHALL toggle visibility
|
||||||
|
- **WHEN** user clicks the expand button on a Level 1 row
|
||||||
|
- **THEN** Level 2 rows (equipment) for that family SHALL toggle visibility
|
||||||
|
- **WHEN** user clicks "Expand All" or "Collapse All" in the toolbar
|
||||||
|
- **THEN** all tree rows SHALL expand or collapse accordingly
|
||||||
|
|
||||||
|
#### Scenario: Matrix cell click filters equipment
|
||||||
|
- **WHEN** user clicks a status count cell in the matrix (e.g., PRD count for a workcenter group)
|
||||||
|
- **THEN** the equipment grid SHALL filter to that workcenter group and status
|
||||||
|
- **THEN** the clicked cell SHALL show a selected visual state
|
||||||
|
- **THEN** a filter indicator banner SHALL display showing active filters
|
||||||
|
- **THEN** clicking the same cell again SHALL remove the filter
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL display equipment card grid
|
||||||
|
The page SHALL show filterable equipment cards with status information.
|
||||||
|
|
||||||
|
#### Scenario: Equipment card rendering
|
||||||
|
- **WHEN** equipment data is loaded
|
||||||
|
- **THEN** cards SHALL display in a responsive grid (auto-fill, min 280px)
|
||||||
|
- **THEN** each card SHALL show: resource name, status badge, workcenter, group, family, location
|
||||||
|
- **THEN** each card SHALL have a colored left border matching its status category
|
||||||
|
|
||||||
|
#### Scenario: LOT information tooltip
|
||||||
|
- **WHEN** user clicks the LOT count indicator on an equipment card
|
||||||
|
- **THEN** a floating tooltip SHALL display with LOT details: LOTID, QTY, track-in time, employee
|
||||||
|
- **THEN** the tooltip SHALL be positioned within the viewport (clamp to edges)
|
||||||
|
- **THEN** clicking outside the tooltip SHALL close it
|
||||||
|
|
||||||
|
#### Scenario: JOB information tooltip
|
||||||
|
- **WHEN** user clicks the JOB indicator on an equipment card
|
||||||
|
- **THEN** a floating tooltip SHALL display with JOB details: order, status, model, stage, technician, symptom/cause/repair codes
|
||||||
|
- **THEN** the tooltip SHALL use the same positioning logic as the LOT tooltip
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL support workcenter and equipment type filtering
|
||||||
|
The page SHALL provide filter controls to narrow the displayed equipment.
|
||||||
|
|
||||||
|
#### Scenario: Workcenter group filter
|
||||||
|
- **WHEN** user selects a workcenter group from the dropdown
|
||||||
|
- **THEN** all data (summary, matrix, equipment) SHALL reload filtered to that group
|
||||||
|
- **THEN** the dropdown options SHALL be loaded from `GET /api/resource/status/options`
|
||||||
|
|
||||||
|
#### Scenario: Equipment type checkboxes
|
||||||
|
- **WHEN** user toggles a checkbox (生產設備, 重點設備, 監控設備)
|
||||||
|
- **THEN** all data SHALL reload with the corresponding filter (is_production, is_key, is_monitor)
|
||||||
|
- **THEN** multiple checkboxes can be active simultaneously
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL display cache status
|
||||||
|
The page SHALL show the real-time cache health and last update time.
|
||||||
|
|
||||||
|
#### Scenario: Cache status indicator
|
||||||
|
- **WHEN** the page loads
|
||||||
|
- **THEN** the page SHALL call `GET /health` to check cache status
|
||||||
|
- **THEN** a green dot SHALL display when cache is loaded and enabled
|
||||||
|
- **THEN** a yellow dot SHALL display when cache is loading
|
||||||
|
- **THEN** a red dot SHALL display when cache is not enabled
|
||||||
|
- **THEN** the last update timestamp SHALL display from equipment status cache metadata
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL auto-refresh and handle request cancellation
|
||||||
|
The page SHALL automatically refresh data and prevent stale request pile-up.
|
||||||
|
|
||||||
|
#### Scenario: Auto-refresh interval
|
||||||
|
- **WHEN** the page is loaded
|
||||||
|
- **THEN** data SHALL auto-refresh every 5 minutes
|
||||||
|
- **THEN** auto-refresh SHALL be skipped when the tab is hidden
|
||||||
|
|
||||||
|
#### Scenario: Visibility change refresh
|
||||||
|
- **WHEN** the tab becomes visible after being hidden
|
||||||
|
- **THEN** data SHALL refresh immediately
|
||||||
|
|
||||||
|
#### Scenario: Manual refresh
|
||||||
|
- **WHEN** user clicks the refresh button
|
||||||
|
- **THEN** data SHALL reload and the auto-refresh timer SHALL reset
|
||||||
|
|
||||||
|
### Requirement: Resource Status page SHALL handle loading and error states
|
||||||
|
The page SHALL display appropriate feedback during API calls and on errors.
|
||||||
|
|
||||||
|
#### Scenario: Initial loading overlay
|
||||||
|
- **WHEN** the page first loads
|
||||||
|
- **THEN** a loading overlay SHALL display until all data is loaded
|
||||||
|
|
||||||
|
#### Scenario: API error handling
|
||||||
|
- **WHEN** an API call fails
|
||||||
|
- **THEN** the affected section SHALL display an error message
|
||||||
|
- **THEN** the page SHALL NOT crash or become unresponsive
|
||||||
@@ -46,6 +46,11 @@ The Vite build configuration SHALL support Vue Single File Components alongside
|
|||||||
- **THEN** Vite SHALL bundle the shared CSS into each page's output CSS
|
- **THEN** Vite SHALL bundle the shared CSS into each page's output CSS
|
||||||
- **THEN** shared CSS SHALL NOT create a separate shared chunk that requires additional HTTP requests
|
- **THEN** shared CSS SHALL NOT create a separate shared chunk that requires additional HTTP requests
|
||||||
|
|
||||||
|
#### Scenario: Shared composable import across module boundaries
|
||||||
|
- **WHEN** a migrated page imports a composable from another shared module (e.g., `resource-status` imports `useAutoRefresh` from `wip-shared/`)
|
||||||
|
- **THEN** the composable SHALL be bundled into the importing page's JS output
|
||||||
|
- **THEN** cross-module imports SHALL NOT create unexpected shared chunks
|
||||||
|
|
||||||
### Requirement: Pure Vite pages SHALL handle API calls without legacy MesApi
|
### Requirement: Pure Vite pages SHALL handle API calls without legacy MesApi
|
||||||
Pure Vite pages SHALL use the existing `frontend/src/core/api.js` module for API communication without depending on the global `window.MesApi` object from `_base.html`.
|
Pure Vite pages SHALL use the existing `frontend/src/core/api.js` module for API communication without depending on the global `window.MesApi` object from `_base.html`.
|
||||||
|
|
||||||
|
|||||||
@@ -438,8 +438,21 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||||||
|
|
||||||
@app.route('/resource')
|
@app.route('/resource')
|
||||||
def resource_page():
|
def resource_page():
|
||||||
"""Resource status report page."""
|
"""Resource status report page served as pure Vite HTML output."""
|
||||||
return render_template('resource_status.html')
|
dist_dir = os.path.join(app.static_folder or "", "dist")
|
||||||
|
dist_html = os.path.join(dist_dir, "resource-status.html")
|
||||||
|
if os.path.exists(dist_html):
|
||||||
|
return send_from_directory(dist_dir, 'resource-status.html')
|
||||||
|
|
||||||
|
# Test/local fallback when frontend build artifacts are absent.
|
||||||
|
return (
|
||||||
|
"<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"UTF-8\">"
|
||||||
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
|
||||||
|
"<title>設備即時概況</title>"
|
||||||
|
"<script type=\"module\" src=\"/static/dist/resource-status.js\"></script>"
|
||||||
|
"</head><body><div id='app'></div></body></html>",
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/excel-query')
|
@app.route('/excel-query')
|
||||||
def excel_query_page():
|
def excel_query_page():
|
||||||
@@ -448,8 +461,21 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||||||
|
|
||||||
@app.route('/resource-history')
|
@app.route('/resource-history')
|
||||||
def resource_history_page():
|
def resource_history_page():
|
||||||
"""Resource history analysis page."""
|
"""Resource history analysis page served as pure Vite HTML output."""
|
||||||
return render_template('resource_history.html')
|
dist_dir = os.path.join(app.static_folder or "", "dist")
|
||||||
|
dist_html = os.path.join(dist_dir, "resource-history.html")
|
||||||
|
if os.path.exists(dist_html):
|
||||||
|
return send_from_directory(dist_dir, 'resource-history.html')
|
||||||
|
|
||||||
|
# Test/local fallback when frontend build artifacts are absent.
|
||||||
|
return (
|
||||||
|
"<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"UTF-8\">"
|
||||||
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
|
||||||
|
"<title>設備歷史績效</title>"
|
||||||
|
"<script type=\"module\" src=\"/static/dist/resource-history.js\"></script>"
|
||||||
|
"</head><body><div id='app'></div></body></html>",
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/tmtt-defect')
|
@app.route('/tmtt-defect')
|
||||||
def tmtt_defect_page():
|
def tmtt_defect_page():
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
Contains Flask Blueprint for historical equipment performance analysis endpoints.
|
Contains Flask Blueprint for historical equipment performance analysis endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, render_template, Response
|
from flask import Blueprint, jsonify, request, redirect, Response
|
||||||
|
|
||||||
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
|
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
|
||||||
from mes_dashboard.config.constants import CACHE_TTL_FILTER_OPTIONS, CACHE_TTL_TREND
|
from mes_dashboard.config.constants import CACHE_TTL_FILTER_OPTIONS, CACHE_TTL_TREND
|
||||||
@@ -27,14 +27,10 @@ resource_history_bp = Blueprint(
|
|||||||
# Page Route (for template rendering)
|
# Page Route (for template rendering)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@resource_history_bp.route('/page', methods=['GET'], endpoint='page_alias')
|
@resource_history_bp.route('/page', methods=['GET'], endpoint='page_alias')
|
||||||
def api_resource_history_page():
|
def api_resource_history_page():
|
||||||
"""Render the resource history analysis page.
|
"""Backward-compatible alias for the migrated /resource-history page route."""
|
||||||
|
return redirect('/resource-history')
|
||||||
Note: The actual page route /resource-history is registered separately
|
|
||||||
in the main app initialization.
|
|
||||||
"""
|
|
||||||
return render_template('resource_history.html')
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -34,64 +34,31 @@ def client(app):
|
|||||||
return app.test_client()
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
class TestResourceHistoryPageAccess:
|
class TestResourceHistoryPageAccess:
|
||||||
"""E2E tests for page access and navigation."""
|
"""E2E tests for page access and navigation."""
|
||||||
|
|
||||||
def test_page_loads_successfully(self, client):
|
def test_page_loads_successfully(self, client):
|
||||||
"""Resource history page should load without errors."""
|
"""Resource history page should load without errors."""
|
||||||
response = client.get('/resource-history')
|
response = client.get('/resource-history')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.data.decode('utf-8')
|
content = response.data.decode('utf-8')
|
||||||
assert '設備歷史績效' in content
|
assert '設備歷史績效' in content
|
||||||
|
|
||||||
def test_page_contains_filter_elements(self, client):
|
def test_page_bootstrap_container_exists(self, client):
|
||||||
"""Page should contain all filter elements."""
|
"""Resource history page should expose the Vue mount container."""
|
||||||
response = client.get('/resource-history')
|
response = client.get('/resource-history')
|
||||||
content = response.data.decode('utf-8')
|
content = response.data.decode('utf-8')
|
||||||
|
|
||||||
# Check for filter elements
|
assert "id='app'" in content or 'id="app"' in content
|
||||||
assert 'startDate' in content
|
|
||||||
assert 'endDate' in content
|
def test_page_references_vite_module(self, client):
|
||||||
# Multi-select dropdowns
|
"""Resource history page should load the Vite module bundle."""
|
||||||
assert 'workcenterGroupsDropdown' in content
|
response = client.get('/resource-history')
|
||||||
assert 'familiesDropdown' in content
|
content = response.data.decode('utf-8')
|
||||||
assert 'isProduction' in content
|
|
||||||
assert 'isKey' in content
|
assert '/static/dist/resource-history.js' in content
|
||||||
assert 'isMonitor' in content
|
assert 'type="module"' in content
|
||||||
|
|
||||||
def test_page_contains_kpi_cards(self, client):
|
|
||||||
"""Page should contain KPI card elements."""
|
|
||||||
response = client.get('/resource-history')
|
|
||||||
content = response.data.decode('utf-8')
|
|
||||||
|
|
||||||
assert 'kpiOuPct' in content
|
|
||||||
assert 'kpiAvailabilityPct' in content
|
|
||||||
assert 'kpiPrdHours' in content
|
|
||||||
assert 'kpiUdtHours' in content
|
|
||||||
assert 'kpiSdtHours' in content
|
|
||||||
assert 'kpiEgtHours' in content
|
|
||||||
assert 'kpiMachineCount' in content
|
|
||||||
|
|
||||||
def test_page_contains_chart_containers(self, client):
|
|
||||||
"""Page should contain chart container elements."""
|
|
||||||
response = client.get('/resource-history')
|
|
||||||
content = response.data.decode('utf-8')
|
|
||||||
|
|
||||||
assert 'trendChart' in content
|
|
||||||
assert 'stackedChart' in content
|
|
||||||
assert 'comparisonChart' in content
|
|
||||||
assert 'heatmapChart' in content
|
|
||||||
|
|
||||||
def test_page_contains_table_elements(self, client):
|
|
||||||
"""Page should contain table elements."""
|
|
||||||
response = client.get('/resource-history')
|
|
||||||
content = response.data.decode('utf-8')
|
|
||||||
|
|
||||||
assert 'detailTableBody' in content
|
|
||||||
assert 'expandAllBtn' in content
|
|
||||||
assert 'collapseAllBtn' in content
|
|
||||||
assert 'exportBtn' in content
|
|
||||||
|
|
||||||
|
|
||||||
class TestResourceHistoryAPIWorkflow:
|
class TestResourceHistoryAPIWorkflow:
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ class TestTemplateIntegration(unittest.TestCase):
|
|||||||
self.assertIn('type="module"', html)
|
self.assertIn('type="module"', html)
|
||||||
self.assertNotIn('mes-toast-container', html)
|
self.assertNotIn('mes-toast-container', html)
|
||||||
|
|
||||||
def test_resource_page_includes_base_scripts(self):
|
def test_resource_page_serves_pure_vite_module(self):
|
||||||
response = self.client.get('/resource')
|
response = self.client.get('/resource')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
html = response.data.decode('utf-8')
|
html = response.data.decode('utf-8')
|
||||||
|
|
||||||
self.assertIn('toast.js', html)
|
self.assertIn('/static/dist/resource-status.js', html)
|
||||||
self.assertIn('mes-api.js', html)
|
self.assertIn('type="module"', html)
|
||||||
self.assertIn('mes-toast-container', html)
|
self.assertNotIn('mes-toast-container', html)
|
||||||
|
|
||||||
def test_excel_query_page_includes_base_scripts(self):
|
def test_excel_query_page_includes_base_scripts(self):
|
||||||
response = self.client.get('/excel-query')
|
response = self.client.get('/excel-query')
|
||||||
|
|||||||
Reference in New Issue
Block a user