diff --git a/README.md b/README.md index 3ccc049..ec3c5e0 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,14 @@ | QC-GATE 即時狀態報表(Vue 3 + Vite) | ✅ 已完成 | | 數據表查詢頁面 Vue 3 遷移 | ✅ 已完成 | | WIP 三頁 Vue 3 遷移(Overview/Detail/Hold Detail) | ✅ 已完成 | +| 設備雙頁 Vue 3 遷移(Status/History) | ✅ 已完成 | | 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 | --- ## 開發歷史(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:完成數據表查詢頁面(`/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。 @@ -423,13 +425,14 @@ sudo systemctl start mes-dashboard mes-dashboard-watchdog - **生產設備**:僅顯示列入生產統計的設備 - **重點設備**:僅顯示標記為重點監控的設備 - **監控設備**:僅顯示需特別監控的設備 -- 設備狀態每 30 秒自動更新 +- 5 分鐘自動刷新 + 手動刷新按鈕 -#### 設備歷史查詢 -1. 選擇查詢日期範圍 -2. 可選擇特定設備或工作中心 -3. 查看歷史趨勢圖表和稼動率熱力圖 -4. 支援 CSV 匯出 +#### 設備歷史績效 +1. 選擇查詢日期範圍與粒度(日/週/月/年) +2. 可多選 workcenter groups 和 families 篩選 +3. 查看趨勢折線、堆疊柱狀、workcenter 對比與 OU% 熱圖 +4. 三層階層明細表顯示各設備 hours 與百分比 +5. 支援 CSV 匯出 ### 管理員登入 @@ -491,20 +494,25 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料 - 10 分鐘自動刷新 + AbortController 請求取消 - **技術架構**:Vue 3 + Vite,URL params 取代 Jinja2 注入 -### 設備狀態監控 +### 設備即時概況 -- 即時設備狀態總覽(PRD/SBY/UDT/SDT/EGT/NST) -- 按工作中心群組統計 -- 設備稼動率(OU%)與運轉率(RUN%) -- 階層篩選(廠區/產線/重點設備/監控設備) -- Redis 快取自動更新(30 秒間隔) +- 即時設備狀態總覽:10 張 KPI 卡片(OU%/AVAIL%/PRD/SBY/UDT/SDT/EGT/NST/OTHER/Total) +- 三層階層矩陣表(workcenter group → family → resource),支援展開/收合與 cell click 篩選 +- 設備卡片格(auto-fill grid),顯示狀態/位置/LOT/JOB 資訊 +- LOT/JOB 浮動 tooltip(`` + viewport clamp 定位) +- 階層篩選(workcenter group 下拉 + 生產設備/重點設備/監控設備 checkbox) +- 5 分鐘自動刷新 + `visibilitychange` 即時刷新 + AbortController +- Cache 狀態指示(green/yellow/red dot + 最後更新時間) +- **技術架構**:Vue 3 + Vite,複用 `resource-shared/` 共用模組與 `useAutoRefresh` composable -### 設備歷史查詢 +### 設備歷史績效 -- 歷史狀態趨勢分析 -- 稼動率熱力圖視覺化 -- 設備狀態明細查詢 -- 支援 CSV 匯出 +- 9 張 KPI 卡片(OU%/AVAIL%/PRD/SBY/UDT/SDT/EGT/NST/機台數) +- 4 個 vue-echarts 圖表:OU%/AVAIL% 趨勢折線、E10 狀態堆疊柱狀、workcenter OU% 橫向對比、OU% 熱圖 +- 三層階層明細表(使用 `resource-shared/HierarchyTable`),hours + percentage 格式 +- 日期區間(預設 7 天)+ 粒度切換(日/週/月/年)+ 多選下拉篩選 +- CSV 串流匯出(`/api/resource/history/export`) +- **技術架構**:Vue 3 + Vite,4 圖表全部使用 `` ### QC-GATE 即時狀態報表 @@ -580,8 +588,8 @@ CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30 | 技術 | 用途 | |------|------| | Jinja2 | 模板引擎(既有頁面) | -| Vue 3 | UI 框架(QC-GATE、Tables、WIP 三頁已遷移,漸進式擴展中) | -| vue-echarts | ECharts Vue 封裝(QC-GATE、WIP Overview Pareto 圖) | +| Vue 3 | UI 框架(QC-GATE、Tables、WIP 三頁、設備雙頁已遷移,漸進式擴展中) | +| vue-echarts | ECharts Vue 封裝(QC-GATE、WIP Overview Pareto、Resource History 4 圖表) | | Vite 6 | 前端多頁模組打包(含 Vue SFC + HTML entry) | | ECharts | 圖表庫(npm tree-shaking + 舊版靜態檔案並存) | | Vanilla JS Modules | 互動功能與頁面邏輯(既有頁面) | @@ -637,8 +645,9 @@ DashBoard_vite/ ├── frontend/ # Vite 前端專案 │ ├── src/core/ # 共用 API/欄位契約/計算 helper │ ├── src/portal/ # Portal entry -│ ├── src/resource-status/ # 設備即時概況 entry -│ ├── src/resource-history/ # 設備歷史績效 entry +│ ├── src/resource-shared/ # 設備雙頁共用 CSS/常數/元件 +│ ├── src/resource-status/ # 設備即時概況 (Vue 3 SFC) +│ ├── src/resource-history/ # 設備歷史績效 (Vue 3 SFC) │ ├── src/job-query/ # 設備維修查詢 entry │ ├── src/excel-query/ # Excel 批次查詢 entry │ ├── 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 +- 完成設備雙頁 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`(`` + 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`): - 三頁共 1,941 行 vanilla JS + Jinja2 模板重寫為 Vue 3 SFC 元件架構 - 抽取 `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 diff --git a/frontend/package.json b/frontend/package.json index 741d020..6484262 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "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" }, "devDependencies": { diff --git a/frontend/src/resource-history/App.vue b/frontend/src/resource-history/App.vue new file mode 100644 index 0000000..72dddde --- /dev/null +++ b/frontend/src/resource-history/App.vue @@ -0,0 +1,317 @@ + + + diff --git a/frontend/src/resource-history/components/ComparisonChart.vue b/frontend/src/resource-history/components/ComparisonChart.vue new file mode 100644 index 0000000..b1639c5 --- /dev/null +++ b/frontend/src/resource-history/components/ComparisonChart.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend/src/resource-history/components/DetailSection.vue b/frontend/src/resource-history/components/DetailSection.vue new file mode 100644 index 0000000..fd39e12 --- /dev/null +++ b/frontend/src/resource-history/components/DetailSection.vue @@ -0,0 +1,247 @@ + + + diff --git a/frontend/src/resource-history/components/FilterBar.vue b/frontend/src/resource-history/components/FilterBar.vue new file mode 100644 index 0000000..4d16d82 --- /dev/null +++ b/frontend/src/resource-history/components/FilterBar.vue @@ -0,0 +1,138 @@ + + + diff --git a/frontend/src/resource-history/components/HeatmapChart.vue b/frontend/src/resource-history/components/HeatmapChart.vue new file mode 100644 index 0000000..65659f9 --- /dev/null +++ b/frontend/src/resource-history/components/HeatmapChart.vue @@ -0,0 +1,120 @@ + + + diff --git a/frontend/src/resource-history/components/KpiCards.vue b/frontend/src/resource-history/components/KpiCards.vue new file mode 100644 index 0000000..c9c31a3 --- /dev/null +++ b/frontend/src/resource-history/components/KpiCards.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/src/resource-history/components/MultiSelect.vue b/frontend/src/resource-history/components/MultiSelect.vue new file mode 100644 index 0000000..39e2f6b --- /dev/null +++ b/frontend/src/resource-history/components/MultiSelect.vue @@ -0,0 +1,154 @@ + + + diff --git a/frontend/src/resource-history/components/StackedChart.vue b/frontend/src/resource-history/components/StackedChart.vue new file mode 100644 index 0000000..955f246 --- /dev/null +++ b/frontend/src/resource-history/components/StackedChart.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/resource-history/components/TrendChart.vue b/frontend/src/resource-history/components/TrendChart.vue new file mode 100644 index 0000000..5d017a5 --- /dev/null +++ b/frontend/src/resource-history/components/TrendChart.vue @@ -0,0 +1,88 @@ + + + diff --git a/frontend/src/resource-history/index.html b/frontend/src/resource-history/index.html new file mode 100644 index 0000000..21868f5 --- /dev/null +++ b/frontend/src/resource-history/index.html @@ -0,0 +1,12 @@ + + + + + + 設備歷史績效 + + +
+ + + diff --git a/frontend/src/resource-history/main.js b/frontend/src/resource-history/main.js index 30ea38f..0c83b02 100644 --- a/frontend/src/resource-history/main.js +++ b/frontend/src/resource-history/main.js @@ -1,844 +1,7 @@ -import { ensureMesApiAvailable } from '../core/api.js'; -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'; +import { createApp } from 'vue'; -ensureMesApiAvailable(); -window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText }; -window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {}; -window.__FIELD_CONTRACTS__['resource_history:detail_table'] = getPageContract('resource_history', 'detail_table'); -window.__FIELD_CONTRACTS__['resource_history:kpi'] = getPageContract('resource_history', 'kpi'); +import App from './App.vue'; +import '../resource-shared/styles.css'; +import './style.css'; -const detailTableFields = getPageContract('resource_history', 'detail_table'); - - -(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 = ` - - ${name} - `; - 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}
- OU%: ${d.ou_pct}%
- AVAIL%: ${d.availability_pct}%
- PRD: ${d.prd_hours}h
- SBY: ${d.sby_hours}h
- 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 `${d.date}
- PRD: ${d.prd_hours}h (${pct(d.prd_hours)}%)
- SBY: ${d.sby_hours}h (${pct(d.sby_hours)}%)
- UDT: ${d.udt_hours}h (${pct(d.udt_hours)}%)
- SDT: ${d.sdt_hours}h (${pct(d.sdt_hours)}%)
- EGT: ${d.egt_hours}h (${pct(d.egt_hours)}%)
- NST: ${d.nst_hours}h (${pct(d.nst_hours)}%)
- Total: ${total.toFixed(1)}h`; - } - }, - 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}
OU%: ${d.ou_pct}%
機台數: ${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]]}
${dates[params.value[0]]}
OU%: ${params.value[2]}%`; - } - }, - 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 = ` - - -
-
🔍
-
無符合條件的資料
-
- - - `; - 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 - ? `` - : ''; - - tr.innerHTML = ` - ${expandBtn}${item.name} - ${item.ou_pct}% - ${item.availability_pct}% - ${formatHoursPct(item.prd_hours, item.prd_pct)} - ${formatHoursPct(item.sby_hours, item.sby_pct)} - ${formatHoursPct(item.udt_hours, item.udt_pct)} - ${formatHoursPct(item.sdt_hours, item.sdt_pct)} - ${formatHoursPct(item.egt_hours, item.egt_pct)} - ${formatHoursPct(item.nst_hours, item.nst_pct)} - ${item.machine_count} - `; - - 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(); -})(); +createApp(App).mount('#app'); diff --git a/frontend/src/resource-history/style.css b/frontend/src/resource-history/style.css new file mode 100644 index 0000000..45fd432 --- /dev/null +++ b/frontend/src/resource-history/style.css @@ -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; + } +} diff --git a/frontend/src/resource-shared/components/HierarchyTable.vue b/frontend/src/resource-shared/components/HierarchyTable.vue new file mode 100644 index 0000000..47d9af0 --- /dev/null +++ b/frontend/src/resource-shared/components/HierarchyTable.vue @@ -0,0 +1,331 @@ + + + diff --git a/frontend/src/resource-shared/constants.js b/frontend/src/resource-shared/constants.js new file mode 100644 index 0000000..9a80ab8 --- /dev/null +++ b/frontend/src/resource-shared/constants.js @@ -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; +} diff --git a/frontend/src/resource-shared/styles.css b/frontend/src/resource-shared/styles.css new file mode 100644 index 0000000..37b2453 --- /dev/null +++ b/frontend/src/resource-shared/styles.css @@ -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; + } +} diff --git a/frontend/src/resource-status/App.vue b/frontend/src/resource-status/App.vue new file mode 100644 index 0000000..8b3adc8 --- /dev/null +++ b/frontend/src/resource-status/App.vue @@ -0,0 +1,444 @@ + + + diff --git a/frontend/src/resource-status/components/EquipmentCard.vue b/frontend/src/resource-status/components/EquipmentCard.vue new file mode 100644 index 0000000..114af0a --- /dev/null +++ b/frontend/src/resource-status/components/EquipmentCard.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/resource-status/components/EquipmentGrid.vue b/frontend/src/resource-status/components/EquipmentGrid.vue new file mode 100644 index 0000000..4e35834 --- /dev/null +++ b/frontend/src/resource-status/components/EquipmentGrid.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/resource-status/components/FilterBar.vue b/frontend/src/resource-status/components/FilterBar.vue new file mode 100644 index 0000000..e6c5c20 --- /dev/null +++ b/frontend/src/resource-status/components/FilterBar.vue @@ -0,0 +1,85 @@ + + + diff --git a/frontend/src/resource-status/components/FloatingTooltip.vue b/frontend/src/resource-status/components/FloatingTooltip.vue new file mode 100644 index 0000000..d962e0c --- /dev/null +++ b/frontend/src/resource-status/components/FloatingTooltip.vue @@ -0,0 +1,186 @@ + + + diff --git a/frontend/src/resource-status/components/MatrixSection.vue b/frontend/src/resource-status/components/MatrixSection.vue new file mode 100644 index 0000000..84a6b58 --- /dev/null +++ b/frontend/src/resource-status/components/MatrixSection.vue @@ -0,0 +1,330 @@ + + + diff --git a/frontend/src/resource-status/components/StatusHeader.vue b/frontend/src/resource-status/components/StatusHeader.vue new file mode 100644 index 0000000..c4dc28e --- /dev/null +++ b/frontend/src/resource-status/components/StatusHeader.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/resource-status/components/SummaryCards.vue b/frontend/src/resource-status/components/SummaryCards.vue new file mode 100644 index 0000000..387937c --- /dev/null +++ b/frontend/src/resource-status/components/SummaryCards.vue @@ -0,0 +1,123 @@ + + + diff --git a/frontend/src/resource-status/index.html b/frontend/src/resource-status/index.html new file mode 100644 index 0000000..a979ef6 --- /dev/null +++ b/frontend/src/resource-status/index.html @@ -0,0 +1,12 @@ + + + + + + 設備即時概況 + + +
+ + + diff --git a/frontend/src/resource-status/main.js b/frontend/src/resource-status/main.js index b2a9020..0c83b02 100644 --- a/frontend/src/resource-status/main.js +++ b/frontend/src/resource-status/main.js @@ -1,853 +1,7 @@ -import { ensureMesApiAvailable } from '../core/api.js'; -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'; +import { createApp } from 'vue'; -ensureMesApiAvailable(); -window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText }; -window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {}; -window.__FIELD_CONTRACTS__['resource_status:matrix_summary'] = getPageContract('resource_status', 'matrix_summary'); +import App from './App.vue'; +import '../resource-shared/styles.css'; +import './style.css'; - - 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 = '
無資料
'; - return; - } - - let html = ` - - - - - - - - - - - - - - - - - `; - - 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 - ? `` - : ''; - - // 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 += ` - - - - - - - - - - - - - `; - - // 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 - ? `` - : ''; - - const famName = fam.name; - const escFamName = famName.replace(/'/g, "\\'"); - - html += ` - - - - - - - - - - - - - `; - - // 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 += ` - - - - - - - - - - - - - `; - }); - } - }); - } - }); - - html += '
工站群組 / 型號 / 機台總數PRDSBYUDTSDTEGTNSTOTHEROU%
${expandBtn}${group.name}${group.counts.total}${group.counts.PRD}${group.counts.SBY}${group.counts.UDT}${group.counts.SDT}${group.counts.EGT}${group.counts.NST}${group.counts.OTHER}${ou}%
${famExpandBtn}${fam.name}${fam.counts.total}${fam.counts.PRD}${fam.counts.SBY}${fam.counts.UDT}${fam.counts.SDT}${fam.counts.EGT}${fam.counts.NST}${fam.counts.OTHER}${famOu}%
${resName}1${status === 'PRD' ? '●' : '-'}${status === 'SBY' ? '●' : '-'}${['UDT', 'PM', 'BKD'].includes(status) ? '●' : '-'}${status === 'SDT' ? '●' : '-'}${['EGT', 'ENG'].includes(status) ? '●' : '-'}${['NST', 'OFF'].includes(status) ? '●' : '-'}${!['PRD', 'SBY', 'UDT', 'PM', 'BKD', 'SDT', 'EGT', 'ENG', 'NST', 'OFF'].includes(status) ? '●' : '-'}${status}
'; - 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 = '
無符合條件的設備
'; - } - } catch (e) { - console.error('載入設備失敗:', e); - container.innerHTML = '
載入失敗
'; - } - } - - // ============================================================ - // 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 '
無批次資料
'; - - let html = '
'; - 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 += ` -
-
${lot.RUNCARDLOTID || '--'}
-
-
數量:${qty} pcs
-
TrackIn:${trackinTime}
-
操作員:${lot.LOTTRACKINEMPLOYEE || '--'}
-
-
- `; - }); - html += '
'; - 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 ` -
- ${label} - ${value || '--'} -
- `; - }; - - return ` -
- ${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)} -
- `; - } - - function renderEquipmentList(equipment) { - const container = document.getElementById('equipmentContainer'); - - if (equipment.length === 0) { - container.innerHTML = '
無符合條件的設備
'; - return; - } - - let html = '
'; - - 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 = `📦 ${eq.LOT_COUNT} 批`; - } - - // Build JOB info with click trigger - let jobHtml = ''; - if (eq.JOBORDER) { - jobHtml = `📋 ${eq.JOBORDER}`; - } - - html += ` -
-
-
${eq.RESOURCENAME || eq.RESOURCEID || '--'}
- ${statusDisplay} -
-
- 📍 ${eq.WORKCENTERNAME || '--'} - 🏭 ${eq.WORKCENTER_GROUP || '--'} - 🔧 ${eq.RESOURCEFAMILYNAME || '--'} - 🏢 ${eq.LOCATIONNAME || '--'} - ${lotHtml} - ${jobHtml} -
-
- `; - }); - - html += '
'; - 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, -}); +createApp(App).mount('#app'); diff --git a/frontend/src/resource-status/style.css b/frontend/src/resource-status/style.css new file mode 100644 index 0000000..6cf4b1a --- /dev/null +++ b/frontend/src/resource-status/style.css @@ -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; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 10f8df1..cd82046 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -16,8 +16,8 @@ export default defineConfig(({ mode }) => ({ 'wip-overview': resolve(__dirname, 'src/wip-overview/index.html'), 'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'), 'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'), - 'resource-status': resolve(__dirname, 'src/resource-status/main.js'), - 'resource-history': resolve(__dirname, 'src/resource-history/main.js'), + 'resource-status': resolve(__dirname, 'src/resource-status/index.html'), + 'resource-history': resolve(__dirname, 'src/resource-history/index.html'), 'job-query': resolve(__dirname, 'src/job-query/main.js'), 'excel-query': resolve(__dirname, 'src/excel-query/main.js'), tables: resolve(__dirname, 'src/tables/index.html'), diff --git a/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/.openspec.yaml b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/.openspec.yaml new file mode 100644 index 0000000..9bc4ae2 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-09 diff --git a/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/design.md b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/design.md new file mode 100644 index 0000000..6ae1198 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/design.md @@ -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 設計 + +**選擇**:單一 `` 元件,透過 `columns` prop 定義欄位、`hierarchy` prop 傳入資料、`@cell-click` 事件處理互動。 +**替代方案**:(a) 遞迴 TreeNode 元件 — 過度設計,三層固定深度不需遞迴;(b) 各頁獨立表格 — 重複。 +**理由**:三層結構固定(group → family → resource),用 `v-for` 嵌套即可,不需泛用遞迴。 + +### D3: vue-echarts 統一 ECharts 使用方式 + +**選擇**:History 頁的 4 個 ECharts 圖表全部改用 ``。 +**替代方案**:直接使用 ECharts API + `onMounted` 手動 init/dispose。 +**理由**:vue-echarts 已用於 QC-GATE 和 WIP Overview,`autoresize` 解決 iframe 隱藏時 width=0 問題。4 個圖表各自封裝為獨立元件。 + +### D4: Status 頁 tooltip 實作 + +**選擇**:自訂 `` 元件 + CSS fixed 定位(移植現有邏輯)。 +**替代方案**:(a) Floating UI 庫 — 新增依賴;(b) 原生 `title` — 太簡陋。 +**理由**:現有 tooltip 邏輯已穩定(viewport clamp + 點擊觸發),Vue 化後更乾淨(`` + `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 頁) + +**選擇**:自訂 `` 元件(移植現有邏輯)。 +**替代方案**: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 但不在此範圍),數量級可接受。 diff --git a/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/proposal.md b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/proposal.md new file mode 100644 index 0000000..c2ebf14 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/proposal.md @@ -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 已安裝) diff --git a/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/resource-history-page/spec.md b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/resource-history-page/spec.md new file mode 100644 index 0000000..ab1c4b9 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/resource-history-page/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/resource-status-page/spec.md b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/resource-status-page/spec.md new file mode 100644 index 0000000..6b73086 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/resource-status-page/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/vue-vite-page-architecture/spec.md b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/vue-vite-page-architecture/spec.md new file mode 100644 index 0000000..e476382 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/specs/vue-vite-page-architecture/spec.md @@ -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 `.html`, `.js`, and `.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 diff --git a/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/tasks.md b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/tasks.md new file mode 100644 index 0000000..d2e8432 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-resource-duo-vue/tasks.md @@ -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,設定 `設備即時概況` +- [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` — `` + `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,設定 `設備歷史績效` +- [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),`` +- [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 匯出 — 點擊按鈕建立臨時 `` 導向 `/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' 允許嵌入 diff --git a/openspec/specs/resource-history-page/spec.md b/openspec/specs/resource-history-page/spec.md new file mode 100644 index 0000000..ab1c4b9 --- /dev/null +++ b/openspec/specs/resource-history-page/spec.md @@ -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 diff --git a/openspec/specs/resource-status-page/spec.md b/openspec/specs/resource-status-page/spec.md new file mode 100644 index 0000000..6b73086 --- /dev/null +++ b/openspec/specs/resource-status-page/spec.md @@ -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 diff --git a/openspec/specs/vue-vite-page-architecture/spec.md b/openspec/specs/vue-vite-page-architecture/spec.md index ff21305..0019618 100644 --- a/openspec/specs/vue-vite-page-architecture/spec.md +++ b/openspec/specs/vue-vite-page-architecture/spec.md @@ -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** 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 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`. diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index 2578f9b..1b2a5f4 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -438,8 +438,21 @@ def create_app(config_name: str | None = None) -> Flask: @app.route('/resource') def resource_page(): - """Resource status report page.""" - return render_template('resource_status.html') + """Resource status report page served as pure Vite HTML output.""" + 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 ( + "" + "" + "設備即時概況" + "" + "
", + 200, + ) @app.route('/excel-query') def excel_query_page(): @@ -448,8 +461,21 @@ def create_app(config_name: str | None = None) -> Flask: @app.route('/resource-history') def resource_history_page(): - """Resource history analysis page.""" - return render_template('resource_history.html') + """Resource history analysis page served as pure Vite HTML output.""" + 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 ( + "" + "" + "設備歷史績效" + "" + "
", + 200, + ) @app.route('/tmtt-defect') def tmtt_defect_page(): diff --git a/src/mes_dashboard/routes/resource_history_routes.py b/src/mes_dashboard/routes/resource_history_routes.py index 54056d7..5e5e084 100644 --- a/src/mes_dashboard/routes/resource_history_routes.py +++ b/src/mes_dashboard/routes/resource_history_routes.py @@ -4,7 +4,7 @@ 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.config.constants import CACHE_TTL_FILTER_OPTIONS, CACHE_TTL_TREND @@ -27,14 +27,10 @@ resource_history_bp = Blueprint( # Page Route (for template rendering) # ============================================================ -@resource_history_bp.route('/page', methods=['GET'], endpoint='page_alias') -def api_resource_history_page(): - """Render the resource history analysis page. - - Note: The actual page route /resource-history is registered separately - in the main app initialization. - """ - return render_template('resource_history.html') +@resource_history_bp.route('/page', methods=['GET'], endpoint='page_alias') +def api_resource_history_page(): + """Backward-compatible alias for the migrated /resource-history page route.""" + return redirect('/resource-history') # ============================================================ diff --git a/src/mes_dashboard/templates/resource_history.html b/src/mes_dashboard/templates/resource_history.html deleted file mode 100644 index e0e1777..0000000 --- a/src/mes_dashboard/templates/resource_history.html +++ /dev/null @@ -1,1531 +0,0 @@ -{% extends "_base.html" %} - -{% block title %}設備歷史績效{% endblock %} - -{% block head_extra %} - - - -{% endblock %} - -{% block content %} -
- -
-

設備歷史績效

-
- - -
-
-
- - -
-
- - -
-
- -
- - - - -
-
-
- -
-
- 全部站點 - -
-
-
-
-
- - -
-
-
-
-
- -
-
- 全部型號 - -
-
-
-
-
- - -
-
-
-
-
- - - -
- -
-
- - -
-
-
OU%
-
--
-
稼動率
-
-
-
AVAIL%
-
--
-
可用率
-
-
-
PRD
-
--
-
生產
-
-
-
SBY
-
--
-
待機
-
-
-
UDT
-
--
-
非計畫停機
-
-
-
SDT
-
--
-
計畫停機
-
-
-
EGT
-
--
-
工程
-
-
-
NST
-
--
-
未排程
-
-
-
機台數
-
--
-
設備總數
-
-
- - -
-
-
OU% / AVAIL% 趨勢
-
-
-
-
E10 狀態分布
-
-
-
- - -
-
-
工站 OU% 對比
-
-
-
-
設備狀態熱力圖
-
-
-
- - -
-
-
明細資料
-
- - - -
-
-
- - - - - - - - - - - - - - - - - - - - -
站點 / 型號 / 機台OU%Availability%PRDSBYUDTSDTEGTNST機台數
-
-
🔍
-
請設定查詢條件後點擊「查詢」
-
-
-
-
-
- - - -{% endblock %} - -{% block scripts %} -{% set resource_history_js = frontend_asset('resource-history.js') %} -{% if resource_history_js %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/src/mes_dashboard/templates/resource_status.html b/src/mes_dashboard/templates/resource_status.html deleted file mode 100644 index cb52874..0000000 --- a/src/mes_dashboard/templates/resource_status.html +++ /dev/null @@ -1,1669 +0,0 @@ -{% extends "_base.html" %} - -{% block title %}設備即時概況{% endblock %} - -{% block head_extra %} - -{% endblock %} - -{% block content %} -
- -
-

設備即時概況

-
-
- - 檢查中... -
- -- -
-
- - -
-
- - -
- - - - - - - - -
- - -
-
-
OU%
-
--
-
稼動率
-
-
-
AVAIL%
-
--
-
可用率
-
-
-
PRD
-
--
-
生產
-
-
-
SBY
-
--
-
待機
-
-
-
UDT
-
--
-
非計畫停機
-
-
-
SDT
-
--
-
計畫停機
-
-
-
EGT
-
--
-
工程
-
-
-
NST
-
--
-
未排程
-
-
-
JOB
-
--
-
有維修單
-
-
-
機台數
-
--
-
設備總數
-
-
- - -
-
- 工站狀態矩陣 -
- - -
-
-
-
- 載入中... -
-
-
- 篩選中: - - -
-
- - -
-
設備清單 (0 台)
-
-
- 載入中... -
-
-
- - -
-
- - -
-
-
-
-{% endblock %} - -{% block scripts %} -{% set resource_status_js = frontend_asset('resource-status.js') %} -{% if resource_status_js %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/tests/e2e/test_resource_history_e2e.py b/tests/e2e/test_resource_history_e2e.py index 9d50e18..fa61a8c 100644 --- a/tests/e2e/test_resource_history_e2e.py +++ b/tests/e2e/test_resource_history_e2e.py @@ -34,64 +34,31 @@ def client(app): return app.test_client() -class TestResourceHistoryPageAccess: - """E2E tests for page access and navigation.""" - - def test_page_loads_successfully(self, client): - """Resource history page should load without errors.""" - response = client.get('/resource-history') - - assert response.status_code == 200 - content = response.data.decode('utf-8') - assert '設備歷史績效' in content - - def test_page_contains_filter_elements(self, client): - """Page should contain all filter elements.""" - response = client.get('/resource-history') - content = response.data.decode('utf-8') - - # Check for filter elements - assert 'startDate' in content - assert 'endDate' in content - # Multi-select dropdowns - assert 'workcenterGroupsDropdown' in content - assert 'familiesDropdown' in content - assert 'isProduction' in content - assert 'isKey' in content - assert 'isMonitor' 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 TestResourceHistoryPageAccess: + """E2E tests for page access and navigation.""" + + def test_page_loads_successfully(self, client): + """Resource history page should load without errors.""" + response = client.get('/resource-history') + + assert response.status_code == 200 + content = response.data.decode('utf-8') + assert '設備歷史績效' in content + + def test_page_bootstrap_container_exists(self, client): + """Resource history page should expose the Vue mount container.""" + response = client.get('/resource-history') + content = response.data.decode('utf-8') + + assert "id='app'" in content or 'id="app"' in content + + def test_page_references_vite_module(self, client): + """Resource history page should load the Vite module bundle.""" + response = client.get('/resource-history') + content = response.data.decode('utf-8') + + assert '/static/dist/resource-history.js' in content + assert 'type="module"' in content class TestResourceHistoryAPIWorkflow: diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 9828854..8c5c621 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -63,14 +63,14 @@ class TestTemplateIntegration(unittest.TestCase): self.assertIn('type="module"', 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') self.assertEqual(response.status_code, 200) html = response.data.decode('utf-8') - self.assertIn('toast.js', html) - self.assertIn('mes-api.js', html) - self.assertIn('mes-toast-container', html) + self.assertIn('/static/dist/resource-status.js', html) + self.assertIn('type="module"', html) + self.assertNotIn('mes-toast-container', html) def test_excel_query_page_includes_base_scripts(self): response = self.client.get('/excel-query')