diff --git a/README.md b/README.md index 71f96ab..3ccc049 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,14 @@ | Portal 動態抽屜導覽管理 | ✅ 已完成 | | QC-GATE 即時狀態報表(Vue 3 + Vite) | ✅ 已完成 | | 數據表查詢頁面 Vue 3 遷移 | ✅ 已完成 | +| WIP 三頁 Vue 3 遷移(Overview/Detail/Hold Detail) | ✅ 已完成 | | 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 | --- ## 開發歷史(Vite 重構後) +- 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。 - 2026-02-09:新增 QC-GATE 即時狀態報表 — 第一個純 Vue 3 + Vite 頁面(脫離 Jinja2),建立後續前端遷移架構模式。 @@ -461,26 +463,33 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料 ### WIP 即時概況 -- 總覽統計(總 LOT 數、總數量、總片數) -- 按 SPEC 和 WORKCENTER 統計 -- 按產品線統計(匯總 + 明細) -- Hold 狀態分類(品質異常/非品質異常) -- 柏拉圖視覺化圖表 +- 總覽統計(Total Lots、Total QTY)+ 狀態卡片(RUN/QUEUE/品質異常/非品質異常) +- Workcenter × Package 矩陣表(Top 15 欄位、sticky 首欄、Total 行列) +- Hold Pareto 分析(品質/非品質分組、ECharts 雙軸柏拉圖 + 明細表) +- Autocomplete 篩選(WORKORDER/LOT ID/PACKAGE/TYPE,cross-filter + 300ms debounce) +- 矩陣點擊 drill-down 至 WIP Detail、Pareto 點擊 drill-down 至 Hold Detail +- 10 分鐘自動刷新 + AbortController 請求取消 +- **技術架構**:Vue 3 + Vite,Pareto 圖使用 vue-echarts ### WIP 明細查詢 -- 依工作中心篩選 -- 依 Package 篩選 -- 依 Hold 狀態篩選 -- 依製程站點篩選 -- 支援 Excel 匯出 +- 依工作中心顯示 LOT 明細(4 sticky 欄 + 動態 Spec 欄位) +- 狀態卡片篩選(RUN/QUEUE/品質異常/非品質異常) +- 點擊 LOT ID 展開 inline 詳細面板(基本/產品/製程/物料/Hold/NCR 資訊) +- Autocomplete 篩選(含 cross-filter)+ 伺服器端分頁 +- URL params 接收 Overview drill-down 參數(workcenter + filters) +- 10 分鐘自動刷新 + AbortController 請求取消 +- **技術架構**:Vue 3 + Vite ### Hold 狀態分析 -- Hold 批次總覽 -- 按 Hold 原因分類 -- Hold 明細查詢 -- 品質異常分類統計 +- 依 Hold 原因顯示摘要統計(Total Lots/QTY/平均滯留/最久滯留/影響站群) +- 品質異常/非品質異常分類(前端常數判斷,紅/橙 gradient header) +- Age 分布卡片篩選(0-1天/1-3天/3-7天/7+天) +- Workcenter/Package 分布表篩選 +- LOT 明細表(10 欄 + 伺服器端分頁 + 篩選指示器) +- 10 分鐘自動刷新 + AbortController 請求取消 +- **技術架構**:Vue 3 + Vite,URL params 取代 Jinja2 注入 ### 設備狀態監控 @@ -571,7 +580,8 @@ CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30 | 技術 | 用途 | |------|------| | Jinja2 | 模板引擎(既有頁面) | -| Vue 3 | UI 框架(新頁面,漸進式遷移中) | +| Vue 3 | UI 框架(QC-GATE、Tables、WIP 三頁已遷移,漸進式擴展中) | +| vue-echarts | ECharts Vue 封裝(QC-GATE、WIP Overview Pareto 圖) | | Vite 6 | 前端多頁模組打包(含 Vue SFC + HTML entry) | | ECharts | 圖表庫(npm tree-shaking + 舊版靜態檔案並存) | | Vanilla JS Modules | 互動功能與頁面邏輯(既有頁面) | @@ -631,8 +641,12 @@ DashBoard_vite/ │ ├── src/resource-history/ # 設備歷史績效 entry │ ├── src/job-query/ # 設備維修查詢 entry │ ├── src/excel-query/ # Excel 批次查詢 entry -│ ├── src/tables/ # 數據表查詢 entry -│ └── src/qc-gate/ # QC-GATE 即時狀態 (Vue 3 SFC) +│ ├── src/tables/ # 數據表查詢 entry (Vue 3 SFC) +│ ├── src/qc-gate/ # QC-GATE 即時狀態 (Vue 3 SFC) +│ ├── src/wip-shared/ # WIP 三頁共用 CSS/常數/元件 +│ ├── src/wip-overview/ # WIP 即時概況 (Vue 3 SFC) +│ ├── src/wip-detail/ # WIP 明細查詢 (Vue 3 SFC) +│ └── src/hold-detail/ # Hold 狀態分析 (Vue 3 SFC) ├── shared/ │ └── field_contracts.json # 前後端共用欄位契約 ├── scripts/ # 腳本 @@ -718,6 +732,14 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce ### 2026-02-09 +- 完成 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 元件 + - Overview:Pareto 圖改用 vue-echarts(``),與 QC-GATE 一致 + - Hold Detail:Jinja2 模板注入(`reason`、`hold_type`)改為前端 URL params + 常數判斷 + - Hold Detail:新增 10 分鐘自動刷新 + `visibilitychange` 即時刷新 + AbortController + - 三頁 Vite entry 從 `main.js` 改為 `index.html`,Flask route 改為 `send_from_directory` + - 移除三份 Jinja2 模板(`wip_overview.html`、`wip_detail.html`、`hold_detail.html`) - 完成數據表查詢頁面(`/tables`)Vue 3 遷移: - 237 行 vanilla JS + Jinja2 模板重寫為 Vue 3 SFC 元件(App.vue、TableCatalog.vue、DataViewer.vue) - Vite entry 從 `main.js` 改為 `index.html`,Flask route 改為 `send_from_directory` @@ -839,5 +861,5 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce --- -**文檔版本**: 5.1 +**文檔版本**: 5.2 **最後更新**: 2026-02-09 diff --git a/frontend/package.json b/frontend/package.json index bdef38a..741d020 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", + "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", "test": "node --test tests/*.test.js" }, "devDependencies": { diff --git a/frontend/src/core/api.js b/frontend/src/core/api.js index 6958eab..e04989d 100644 --- a/frontend/src/core/api.js +++ b/frontend/src/core/api.js @@ -33,59 +33,160 @@ function buildApiError(response, payload) { return error; } -async function fetchJson(url, options = {}) { - const timeout = options.timeout ?? DEFAULT_TIMEOUT; +function buildUrlWithParams(url, params) { + if (!params || typeof params !== 'object') { + return url; + } + + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === null || value === undefined || value === '') { + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => { + if (item !== null && item !== undefined && item !== '') { + searchParams.append(key, String(item)); + } + }); + return; + } + + searchParams.append(key, String(value)); + }); + + const query = searchParams.toString(); + if (!query) { + return url; + } + + return url.includes('?') ? `${url}&${query}` : `${url}?${query}`; +} + +function isExternalMesApiBridge(candidate) { + return Boolean(candidate?.get) && !candidate.__mesApiBridge; +} + +function createAbortSignal(timeoutMs, externalSignal) { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); + let timeoutId = null; + let onAbort = null; + + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + timeoutId = setTimeout(() => { + controller.abort(); + }, timeoutMs); + } + + if (externalSignal) { + if (externalSignal.aborted) { + controller.abort(); + } else { + onAbort = () => controller.abort(); + externalSignal.addEventListener('abort', onAbort, { once: true }); + } + } + + return { + signal: controller.signal, + cleanup() { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (externalSignal && onAbort) { + externalSignal.removeEventListener('abort', onAbort); + } + }, + }; +} + +async function parseResponsePayload(response) { + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + return response.json(); + } + + const rawText = await response.text(); + try { + return JSON.parse(rawText); + } catch { + return { message: rawText }; + } +} + +async function fetchJson(url, options = {}) { + const { + timeout = DEFAULT_TIMEOUT, + params, + signal: externalSignal, + ...fetchOptions + } = options; + + const requestUrl = buildUrlWithParams(url, params); + const { signal, cleanup } = createAbortSignal(timeout, externalSignal); try { - const response = await fetch(url, { - ...options, - signal: controller.signal + const response = await fetch(requestUrl, { + ...fetchOptions, + signal, }); - const data = await response.json(); + const data = await parseResponsePayload(response); if (!response.ok) { throw buildApiError(response, data); } return data; } finally { - clearTimeout(timer); + cleanup(); } } export async function apiGet(url, options = {}) { - if (window.MesApi?.get) { + if (isExternalMesApiBridge(window.MesApi)) { return window.MesApi.get(url, options); } return fetchJson(url, { ...options, method: 'GET' }); } export async function apiPost(url, payload, options = {}) { - if (window.MesApi?.post) { + if (isExternalMesApiBridge(window.MesApi) && window.MesApi?.post) { const enrichedOptions = { ...options, - headers: withCsrfHeaders(options.headers || {}, 'POST') + headers: withCsrfHeaders(options.headers || {}, 'POST'), }; return window.MesApi.post(url, payload, enrichedOptions); } + return fetchJson(url, { ...options, method: 'POST', - headers: withCsrfHeaders({ - 'Content-Type': 'application/json', - ...(options.headers || {}) - }, 'POST'), - body: JSON.stringify(payload) + headers: withCsrfHeaders( + { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + 'POST' + ), + body: JSON.stringify(payload), }); } export async function apiUpload(url, formData, options = {}) { + if (isExternalMesApiBridge(window.MesApi) && window.MesApi?.post) { + const enrichedOptions = { + ...options, + headers: withCsrfHeaders(options.headers || {}, 'POST'), + }; + return window.MesApi.post(url, formData, enrichedOptions); + } + return fetchJson(url, { ...options, method: 'POST', headers: withCsrfHeaders(options.headers || {}, 'POST'), - body: formData + body: formData, }); } @@ -95,9 +196,32 @@ export function ensureMesApiAvailable() { } const bridge = { - get: (url, options) => apiGet(url, options), - post: (url, payload, options) => apiPost(url, payload, options) + __mesApiBridge: true, + get(url, options) { + return fetchJson(url, { ...options, method: 'GET' }); + }, + post(url, payload, options = {}) { + const method = options.method || 'POST'; + const headers = withCsrfHeaders( + { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + method + ); + + const body = payload instanceof FormData ? payload : JSON.stringify(payload); + const normalizedHeaders = payload instanceof FormData ? withCsrfHeaders(options.headers || {}, method) : headers; + + return fetchJson(url, { + ...options, + method, + headers: normalizedHeaders, + body, + }); + }, }; + window.MesApi = bridge; return bridge; } diff --git a/frontend/src/hold-detail/App.vue b/frontend/src/hold-detail/App.vue new file mode 100644 index 0000000..de04a00 --- /dev/null +++ b/frontend/src/hold-detail/App.vue @@ -0,0 +1,316 @@ + + + diff --git a/frontend/src/hold-detail/components/AgeDistribution.vue b/frontend/src/hold-detail/components/AgeDistribution.vue new file mode 100644 index 0000000..498c14b --- /dev/null +++ b/frontend/src/hold-detail/components/AgeDistribution.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/hold-detail/components/DistributionTable.vue b/frontend/src/hold-detail/components/DistributionTable.vue new file mode 100644 index 0000000..f3a460b --- /dev/null +++ b/frontend/src/hold-detail/components/DistributionTable.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/hold-detail/components/LotTable.vue b/frontend/src/hold-detail/components/LotTable.vue new file mode 100644 index 0000000..d9661f2 --- /dev/null +++ b/frontend/src/hold-detail/components/LotTable.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/src/hold-detail/components/SummaryCards.vue b/frontend/src/hold-detail/components/SummaryCards.vue new file mode 100644 index 0000000..450bb63 --- /dev/null +++ b/frontend/src/hold-detail/components/SummaryCards.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/hold-detail/index.html b/frontend/src/hold-detail/index.html new file mode 100644 index 0000000..453d5fe --- /dev/null +++ b/frontend/src/hold-detail/index.html @@ -0,0 +1,12 @@ + + + + + + Hold Detail + + +
+ + + diff --git a/frontend/src/hold-detail/main.js b/frontend/src/hold-detail/main.js index 7adb45e..3cb2340 100644 --- a/frontend/src/hold-detail/main.js +++ b/frontend/src/hold-detail/main.js @@ -1,336 +1,6 @@ -import { ensureMesApiAvailable } from '../core/api.js'; -import { escapeHtml, safeText } from '../core/table-tree.js'; +import { createApp } from 'vue'; -ensureMesApiAvailable(); +import App from './App.vue'; +import './style.css'; -(function initHoldDetailPage() { - // ============================================================ - // State - // ============================================================ - const state = { - reason: new URLSearchParams(window.location.search).get('reason') || '', - summary: null, - distribution: null, - lots: null, - page: 1, - perPage: 50, - filters: { - workcenter: null, - package: null, - ageRange: null - } - }; - - // ============================================================ - // Utility - // ============================================================ - function formatNumber(num) { - if (num === null || num === undefined || num === '-') return '-'; - return num.toLocaleString('zh-TW'); - } - - function jsSingleQuote(value) { - return safeText(value, '') - .replace(/\\/g, '\\\\') - .replace(/'/g, "\\'"); - } - - // ============================================================ - // API Functions - // ============================================================ - const API_TIMEOUT = 60000; - - async function fetchSummary() { - const result = await MesApi.get('/api/wip/hold-detail/summary', { - params: { reason: state.reason }, - timeout: API_TIMEOUT - }); - if (result.success) return result.data; - throw new Error(result.error); - } - - async function fetchDistribution() { - const result = await MesApi.get('/api/wip/hold-detail/distribution', { - params: { reason: state.reason }, - timeout: API_TIMEOUT - }); - if (result.success) return result.data; - throw new Error(result.error); - } - - async function fetchLots() { - const params = { - reason: state.reason, - page: state.page, - per_page: state.perPage - }; - if (state.filters.workcenter) params.workcenter = state.filters.workcenter; - if (state.filters.package) params.package = state.filters.package; - if (state.filters.ageRange) params.age_range = state.filters.ageRange; - - const result = await MesApi.get('/api/wip/hold-detail/lots', { - params, - timeout: API_TIMEOUT - }); - if (result.success) return result.data; - throw new Error(result.error); - } - - // ============================================================ - // Render Functions - // ============================================================ - function renderSummary(data) { - document.getElementById('totalLots').textContent = formatNumber(data.totalLots); - document.getElementById('totalQty').textContent = formatNumber(data.totalQty); - document.getElementById('avgAge').textContent = data.avgAge ? `${data.avgAge}天` : '-'; - document.getElementById('maxAge').textContent = data.maxAge ? `${data.maxAge}天` : '-'; - document.getElementById('workcenterCount').textContent = formatNumber(data.workcenterCount); - } - - function renderDistribution(data) { - // Age distribution - const ageMap = {}; - data.byAge.forEach(item => { ageMap[item.range] = item; }); - - const age01 = ageMap['0-1'] || { lots: 0, qty: 0, percentage: 0 }; - const age13 = ageMap['1-3'] || { lots: 0, qty: 0, percentage: 0 }; - const age37 = ageMap['3-7'] || { lots: 0, qty: 0, percentage: 0 }; - const age7 = ageMap['7+'] || { lots: 0, qty: 0, percentage: 0 }; - - document.getElementById('age01Lots').textContent = formatNumber(age01.lots); - document.getElementById('age01Qty').textContent = formatNumber(age01.qty); - document.getElementById('age01Pct').textContent = `${age01.percentage}%`; - - document.getElementById('age13Lots').textContent = formatNumber(age13.lots); - document.getElementById('age13Qty').textContent = formatNumber(age13.qty); - document.getElementById('age13Pct').textContent = `${age13.percentage}%`; - - document.getElementById('age37Lots').textContent = formatNumber(age37.lots); - document.getElementById('age37Qty').textContent = formatNumber(age37.qty); - document.getElementById('age37Pct').textContent = `${age37.percentage}%`; - - document.getElementById('age7Lots').textContent = formatNumber(age7.lots); - document.getElementById('age7Qty').textContent = formatNumber(age7.qty); - document.getElementById('age7Pct').textContent = `${age7.percentage}%`; - - // Workcenter table - const wcBody = document.getElementById('workcenterBody'); - if (data.byWorkcenter.length === 0) { - wcBody.innerHTML = 'No data'; - } else { - wcBody.innerHTML = data.byWorkcenter.map(item => ` - - ${escapeHtml(safeText(item.name))} - ${escapeHtml(formatNumber(item.lots))} - ${escapeHtml(formatNumber(item.qty))} - ${escapeHtml(safeText(item.percentage, 0))}% - - `).join(''); - } - - // Package table - const pkgBody = document.getElementById('packageBody'); - if (data.byPackage.length === 0) { - pkgBody.innerHTML = 'No data'; - } else { - pkgBody.innerHTML = data.byPackage.map(item => ` - - ${escapeHtml(safeText(item.name))} - ${escapeHtml(formatNumber(item.lots))} - ${escapeHtml(formatNumber(item.qty))} - ${escapeHtml(safeText(item.percentage, 0))}% - - `).join(''); - } - } - - function renderLots(data) { - const tbody = document.getElementById('lotBody'); - const lots = data.lots; - - if (lots.length === 0) { - tbody.innerHTML = 'No data'; - document.getElementById('tableInfo').textContent = 'No data'; - document.getElementById('pagination').style.display = 'none'; - return; - } - - tbody.innerHTML = lots.map(lot => ` - - ${escapeHtml(safeText(lot.lotId))} - ${escapeHtml(safeText(lot.workorder))} - ${escapeHtml(formatNumber(lot.qty))} - ${escapeHtml(safeText(lot.package))} - ${escapeHtml(safeText(lot.workcenter))} - ${escapeHtml(safeText(lot.spec))} - ${escapeHtml(safeText(lot.age))}天 - ${escapeHtml(safeText(lot.holdBy))} - ${escapeHtml(safeText(lot.dept))} - ${escapeHtml(safeText(lot.holdComment))} - - `).join(''); - - // Update pagination - const pg = data.pagination; - const start = (pg.page - 1) * pg.perPage + 1; - const end = Math.min(pg.page * pg.perPage, pg.total); - document.getElementById('tableInfo').textContent = `顯示 ${start} - ${end} / ${formatNumber(pg.total)}`; - - if (pg.totalPages > 1) { - document.getElementById('pagination').style.display = 'flex'; - document.getElementById('pageInfo').textContent = `Page ${pg.page} / ${pg.totalPages}`; - document.getElementById('btnPrev').disabled = pg.page <= 1; - document.getElementById('btnNext').disabled = pg.page >= pg.totalPages; - } else { - document.getElementById('pagination').style.display = 'none'; - } - } - - function updateFilterIndicator() { - const indicator = document.getElementById('filterIndicator'); - const text = document.getElementById('filterText'); - const parts = []; - - if (state.filters.workcenter) parts.push(`Workcenter=${state.filters.workcenter}`); - if (state.filters.package) parts.push(`Package=${state.filters.package}`); - if (state.filters.ageRange) parts.push(`Age=${state.filters.ageRange}天`); - - if (parts.length > 0) { - text.textContent = '篩選: ' + parts.join(', '); - indicator.style.display = 'flex'; - } else { - indicator.style.display = 'none'; - } - - // Update active states - document.querySelectorAll('.age-card').forEach(card => { - card.classList.toggle('active', card.dataset.range === state.filters.ageRange); - }); - document.querySelectorAll('#workcenterBody tr').forEach(row => { - row.classList.toggle('active', row.dataset.workcenter === state.filters.workcenter); - }); - document.querySelectorAll('#packageBody tr').forEach(row => { - row.classList.toggle('active', row.dataset.package === state.filters.package); - }); - } - - // ============================================================ - // Filter Functions - // ============================================================ - function toggleAgeFilter(range) { - state.filters.ageRange = state.filters.ageRange === range ? null : range; - state.page = 1; - updateFilterIndicator(); - loadLots(); - } - - function toggleWorkcenterFilter(wc) { - state.filters.workcenter = state.filters.workcenter === wc ? null : wc; - state.page = 1; - updateFilterIndicator(); - loadLots(); - } - - function togglePackageFilter(pkg) { - state.filters.package = state.filters.package === pkg ? null : pkg; - state.page = 1; - updateFilterIndicator(); - loadLots(); - } - - function clearFilters() { - state.filters = { workcenter: null, package: null, ageRange: null }; - state.page = 1; - updateFilterIndicator(); - loadLots(); - } - - // ============================================================ - // Pagination - // ============================================================ - function prevPage() { - if (state.page > 1) { - state.page--; - loadLots(); - } - } - - function nextPage() { - if (state.lots && state.page < state.lots.pagination.totalPages) { - state.page++; - loadLots(); - } - } - - // ============================================================ - // Data Loading - // ============================================================ - async function loadLots() { - document.getElementById('lotBody').innerHTML = 'Loading...'; - document.getElementById('refreshIndicator').classList.add('active'); - - try { - state.lots = await fetchLots(); - renderLots(state.lots); - } catch (error) { - console.error('Load lots failed:', error); - document.getElementById('lotBody').innerHTML = 'Error loading data'; - } finally { - document.getElementById('refreshIndicator').classList.remove('active'); - } - } - - async function loadAllData(showOverlay = true) { - if (showOverlay) { - document.getElementById('loadingOverlay').style.display = 'flex'; - } - document.getElementById('refreshIndicator').classList.add('active'); - - try { - const [summary, distribution, lots] = await Promise.all([ - fetchSummary(), - fetchDistribution(), - fetchLots() - ]); - - state.summary = summary; - state.distribution = distribution; - state.lots = lots; - - renderSummary(summary); - renderDistribution(distribution); - renderLots(lots); - updateFilterIndicator(); - - document.getElementById('lastUpdate').textContent = `Last Update: ${new Date().toLocaleString('zh-TW')}`; - } catch (error) { - console.error('Load data failed:', error); - } finally { - document.getElementById('loadingOverlay').style.display = 'none'; - document.getElementById('refreshIndicator').classList.remove('active'); - } - } - - function manualRefresh() { - loadAllData(false); - } - - // ============================================================ - // Initialize - // ============================================================ - window.onload = function() { - loadAllData(true); - }; - - Object.assign(window, { - toggleAgeFilter, - toggleWorkcenterFilter, - togglePackageFilter, - clearFilters, - prevPage, - nextPage, - manualRefresh, - loadAllData, - loadLots - }); -})(); +createApp(App).mount('#app'); diff --git a/frontend/src/hold-detail/style.css b/frontend/src/hold-detail/style.css new file mode 100644 index 0000000..5870d77 --- /dev/null +++ b/frontend/src/hold-detail/style.css @@ -0,0 +1,263 @@ +@import '../wip-shared/styles.css'; + +.hold-detail-page .header h1 { + font-size: 20px; +} + +.hold-type-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.error-banner { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 6px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; +} + +.hold-summary-row { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.section-title { + font-size: 14px; + font-weight: 600; + color: var(--muted); + margin-bottom: 12px; +} + +.age-distribution { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 16px; +} + +.age-card { + background: var(--card-bg); + border-radius: 10px; + padding: 16px; + border: 2px solid var(--border); + box-shadow: var(--shadow); + cursor: pointer; + transition: all 0.2s ease; +} + +.age-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); +} + +.age-card.active { + border-color: var(--primary); + background: #f0f4ff; +} + +.age-label { + font-size: 14px; + font-weight: 600; + margin-bottom: 10px; + color: var(--text); +} + +.age-stats { + display: flex; + flex-direction: column; + gap: 4px; +} + +.age-stat { + display: flex; + justify-content: space-between; + font-size: 13px; +} + +.age-stat .label { + color: var(--muted); +} + +.age-stat .value { + font-weight: 600; +} + +.age-percentage { + font-size: 20px; + font-weight: 700; + color: var(--primary); + margin-top: 8px; + text-align: right; +} + +.distribution-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.card { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.card-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: #fafbfc; +} + +.card-title { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.card-body { + padding: 0; + max-height: 300px; + overflow-y: auto; +} + +.dist-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.dist-table th, +.dist-table td { + padding: 10px 16px; + border-bottom: 1px solid #f0f0f0; +} + +.dist-table th { + background: #f8f9fa; + font-weight: 600; + text-align: left; +} + +.dist-table th:nth-child(2), +.dist-table th:nth-child(3), +.dist-table th:nth-child(4), +.dist-table td:nth-child(2), +.dist-table td:nth-child(3), +.dist-table td:nth-child(4) { + text-align: right; +} + +.dist-table tbody tr { + cursor: pointer; + transition: background 0.15s ease; +} + +.dist-table tbody tr:hover { + background: #f0f4ff; +} + +.dist-table tbody tr.active { + background: #e0e7ff; +} + +.table-section { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.table-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: #fafbfc; + flex-wrap: wrap; + gap: 12px; +} + +.table-title { + font-size: 16px; + font-weight: 600; + color: var(--text); +} + +.table-info { + font-size: 13px; + color: var(--muted); +} + +.table-container { + overflow-x: auto; + max-height: 500px; +} + +.lot-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.lot-table th, +.lot-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #f0f0f0; + white-space: nowrap; +} + +.lot-table th { + background: #f8f9fa; + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; +} + +.lot-table th:nth-child(3), +.lot-table th:nth-child(7), +.lot-table td:nth-child(3), +.lot-table td:nth-child(7) { + text-align: right; +} + +.lot-table tbody tr:hover { + background: #f8f9fc; +} + +@media (max-width: 1400px) { + .hold-summary-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .age-distribution { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1000px) { + .hold-summary-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .distribution-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .hold-summary-row, + .age-distribution { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/wip-detail/App.vue b/frontend/src/wip-detail/App.vue new file mode 100644 index 0000000..dfbc901 --- /dev/null +++ b/frontend/src/wip-detail/App.vue @@ -0,0 +1,328 @@ + + + diff --git a/frontend/src/wip-detail/components/FilterPanel.vue b/frontend/src/wip-detail/components/FilterPanel.vue new file mode 100644 index 0000000..58a9c12 --- /dev/null +++ b/frontend/src/wip-detail/components/FilterPanel.vue @@ -0,0 +1,110 @@ + + + diff --git a/frontend/src/wip-detail/components/LotDetailPanel.vue b/frontend/src/wip-detail/components/LotDetailPanel.vue new file mode 100644 index 0000000..5e02649 --- /dev/null +++ b/frontend/src/wip-detail/components/LotDetailPanel.vue @@ -0,0 +1,198 @@ + + + diff --git a/frontend/src/wip-detail/components/LotTable.vue b/frontend/src/wip-detail/components/LotTable.vue new file mode 100644 index 0000000..2c553d8 --- /dev/null +++ b/frontend/src/wip-detail/components/LotTable.vue @@ -0,0 +1,143 @@ + + + diff --git a/frontend/src/wip-detail/components/SummaryCards.vue b/frontend/src/wip-detail/components/SummaryCards.vue new file mode 100644 index 0000000..cfc170c --- /dev/null +++ b/frontend/src/wip-detail/components/SummaryCards.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/wip-detail/index.html b/frontend/src/wip-detail/index.html new file mode 100644 index 0000000..5e248d5 --- /dev/null +++ b/frontend/src/wip-detail/index.html @@ -0,0 +1,12 @@ + + + + + + WIP Detail Dashboard + + +
+ + + diff --git a/frontend/src/wip-detail/main.js b/frontend/src/wip-detail/main.js index 0b8750b..3cb2340 100644 --- a/frontend/src/wip-detail/main.js +++ b/frontend/src/wip-detail/main.js @@ -1,821 +1,6 @@ -import { ensureMesApiAvailable } from '../core/api.js'; -import { - debounce, - fetchWipAutocompleteItems, -} from '../core/autocomplete.js'; -import { buildWipDetailQueryParams } from '../core/wip-derive.js'; +import { createApp } from 'vue'; -ensureMesApiAvailable(); +import App from './App.vue'; +import './style.css'; -(function initWipDetailPage() { - // ============================================================ - // State Management - // ============================================================ - const state = { - workcenter: '', - data: null, - packages: [], - page: 1, - pageSize: 100, - filters: { - package: '', - type: '', - workorder: '', - lotid: '' - }, - isLoading: false, - refreshTimer: null, - REFRESH_INTERVAL: 10 * 60 * 1000, // 10 minutes - }; - - // WIP Status filter (separate from other filters) - let activeStatusFilter = null; // null | 'run' | 'queue' | 'quality-hold' | 'non-quality-hold' - - // AbortController for cancelling in-flight requests - let tableAbortController = null; // For loadTableOnly() - let loadAllAbortController = null; // For loadAllData() - - // ============================================================ - // Utility Functions - // ============================================================ - function formatNumber(num) { - if (num === null || num === undefined || num === '-') return '-'; - return num.toLocaleString('zh-TW'); - } - - function updateElementWithTransition(elementId, newValue) { - const el = document.getElementById(elementId); - const oldValue = el.textContent; - const formattedNew = formatNumber(newValue); - - if (oldValue !== formattedNew) { - el.textContent = formattedNew; - el.classList.add('updated'); - setTimeout(() => el.classList.remove('updated'), 500); - } - } - - function getUrlParam(name) { - const params = new URLSearchParams(window.location.search); - return params.get(name) || ''; - } - - // ============================================================ - // API Functions (using MesApi) - // ============================================================ - const API_TIMEOUT = 60000; // 60 seconds timeout - - async function fetchPackages() { - const result = await MesApi.get('/api/wip/meta/packages', { silent: true, timeout: API_TIMEOUT }); - if (result.success) { - return result.data; - } - throw new Error(result.error || 'Failed to fetch packages'); - } - - async function fetchDetail(signal = null) { - const params = buildWipDetailQueryParams({ - page: state.page, - pageSize: state.pageSize, - filters: state.filters, - statusFilter: activeStatusFilter, - }); - - const result = await MesApi.get(`/api/wip/detail/${encodeURIComponent(state.workcenter)}`, { - params, - timeout: API_TIMEOUT, - signal - }); - if (result.success) { - return result.data; - } - throw new Error(result.error || 'Failed to fetch detail'); - } - - async function fetchWorkcenters() { - const result = await MesApi.get('/api/wip/meta/workcenters', { silent: true, timeout: API_TIMEOUT }); - if (result.success) { - return result.data; - } - throw new Error(result.error || 'Failed to fetch workcenters'); - } - - async function searchAutocompleteItems(type, query) { - return fetchWipAutocompleteItems({ - searchType: type, - query, - filters: { - workorder: document.getElementById('filterWorkorder').value, - lotid: document.getElementById('filterLotid').value, - package: document.getElementById('filterPackage').value, - type: document.getElementById('filterType').value, - }, - request: (url, options) => MesApi.get(url, options), - }); - } - - // ============================================================ - // Render Functions - // ============================================================ - function renderSummary(summary) { - if (!summary) return; - - updateElementWithTransition('totalLots', summary.totalLots); - updateElementWithTransition('runLots', summary.runLots); - updateElementWithTransition('queueLots', summary.queueLots); - updateElementWithTransition('qualityHoldLots', summary.qualityHoldLots); - updateElementWithTransition('nonQualityHoldLots', summary.nonQualityHoldLots); - } - - function renderTable(data) { - const container = document.getElementById('tableContainer'); - - if (!data || !data.lots || data.lots.length === 0) { - container.innerHTML = '
No data available
'; - document.getElementById('tableInfo').textContent = 'No data'; - document.getElementById('pagination').style.display = 'none'; - return; - } - - const specs = data.specs || []; - - let html = ''; - // Fixed columns - html += ''; - html += ''; - html += ''; - html += ''; - - // Spec columns - specs.forEach(spec => { - html += ``; - }); - - html += ''; - - data.lots.forEach(lot => { - html += ''; - - // Fixed columns - LOT ID is clickable - const lotIdDisplay = lot.lotId - ? `${lot.lotId}` - : '-'; - html += ``; - html += ``; - - // WIP Status with color and hold reason - const statusClass = `wip-status-${(lot.wipStatus || 'queue').toLowerCase()}`; - let statusText = lot.wipStatus || 'QUEUE'; - if (lot.wipStatus === 'HOLD' && lot.holdReason) { - statusText = `HOLD (${lot.holdReason})`; - } - html += ``; - - html += ``; - - // Spec columns - show QTY in matching spec column - specs.forEach(spec => { - if (lot.spec === spec) { - html += ``; - } else { - html += ''; - } - }); - - html += ''; - }); - - html += '
LOT IDEquipmentWIP StatusPackage${spec}
${lotIdDisplay}${lot.equipment || '-'}${statusText}${lot.package || '-'}${formatNumber(lot.qty)}
'; - container.innerHTML = html; - - // Update info - const pagination = data.pagination; - const start = (pagination.page - 1) * pagination.page_size + 1; - const end = Math.min(pagination.page * pagination.page_size, pagination.total_count); - document.getElementById('tableInfo').textContent = - `Showing ${start} - ${end} of ${formatNumber(pagination.total_count)}`; - - // Update pagination - if (pagination.total_pages > 1) { - document.getElementById('pagination').style.display = 'flex'; - document.getElementById('pageInfo').textContent = - `Page ${pagination.page} / ${pagination.total_pages}`; - document.getElementById('btnPrev').disabled = pagination.page <= 1; - document.getElementById('btnNext').disabled = pagination.page >= pagination.total_pages; - } else { - document.getElementById('pagination').style.display = 'none'; - } - - // Update last update time - if (data.sys_date) { - document.getElementById('lastUpdate').textContent = `Last Update: ${data.sys_date}`; - } - } - - function populatePackageFilter(packages) { - const select = document.getElementById('filterPackage'); - const currentValue = select.value; - - select.innerHTML = ''; - packages.forEach(pkg => { - const option = document.createElement('option'); - option.value = pkg.name; - option.textContent = `${pkg.name} (${pkg.lot_count})`; - select.appendChild(option); - }); - - select.value = currentValue; - } - - // ============================================================ - // Data Loading - // ============================================================ - async function loadAllData(showOverlay = true) { - // Cancel any in-flight request to prevent connection pile-up - if (loadAllAbortController) { - loadAllAbortController.abort(); - console.log('[WIP Detail] Previous request cancelled'); - } - loadAllAbortController = new AbortController(); - const signal = loadAllAbortController.signal; - - state.isLoading = true; - - if (showOverlay) { - document.getElementById('loadingOverlay').style.display = 'flex'; - } - - // Show refresh indicator - document.getElementById('refreshIndicator').classList.add('active'); - document.getElementById('refreshError').classList.remove('active'); - document.getElementById('refreshSuccess').classList.remove('active'); - - try { - // Load packages for filter (non-blocking - don't fail if this times out) - if (state.packages.length === 0) { - try { - state.packages = await fetchPackages(); - populatePackageFilter(state.packages); - } catch (pkgError) { - console.warn('Failed to load packages filter:', pkgError); - } - } - - // Load detail data (main data - this is critical) - state.data = await fetchDetail(signal); - - renderSummary(state.data.summary); - renderTable(state.data); - - // Show success indicator - document.getElementById('refreshSuccess').classList.add('active'); - setTimeout(() => { - document.getElementById('refreshSuccess').classList.remove('active'); - }, 1500); - - } catch (error) { - // Ignore abort errors (expected when user triggers new request) - if (error.name === 'AbortError') { - console.log('[WIP Detail] Request cancelled (new request started)'); - return; - } - console.error('Data load failed:', error); - document.getElementById('refreshError').classList.add('active'); - } finally { - state.isLoading = false; - document.getElementById('loadingOverlay').style.display = 'none'; - document.getElementById('refreshIndicator').classList.remove('active'); - } - } - - // ============================================================ - // Autocomplete Functions - // ============================================================ - function showDropdown(dropdownId, items, onSelect) { - const dropdown = document.getElementById(dropdownId); - if (!items || items.length === 0) { - dropdown.innerHTML = '
No results
'; - dropdown.classList.add('show'); - return; - } - dropdown.innerHTML = items.map(item => - `
${item}
` - ).join(''); - dropdown.classList.add('show'); - - dropdown.querySelectorAll('.autocomplete-item').forEach(el => { - el.addEventListener('click', () => { - onSelect(el.dataset.value); - dropdown.classList.remove('show'); - }); - }); - } - - function hideDropdown(dropdownId) { - document.getElementById(dropdownId).classList.remove('show'); - } - - function showLoading(dropdownId) { - const dropdown = document.getElementById(dropdownId); - dropdown.innerHTML = '
Searching...
'; - dropdown.classList.add('show'); - } - - function setupAutocomplete(inputId, dropdownId, searchType) { - const input = document.getElementById(inputId); - - const doSearch = debounce(async (query) => { - if (query.length < 2) { - hideDropdown(dropdownId); - return; - } - showLoading(dropdownId); - try { - const items = await searchAutocompleteItems(searchType, query); - showDropdown(dropdownId, items, (value) => { - input.value = value; - }); - } catch (e) { - hideDropdown(dropdownId); - } - }, 300); - - input.addEventListener('input', (e) => { - doSearch(e.target.value); - }); - - input.addEventListener('focus', (e) => { - if (e.target.value.length >= 2) { - doSearch(e.target.value); - } - }); - - // Hide dropdown when clicking outside - document.addEventListener('click', (e) => { - if (!e.target.closest(`#${inputId}`) && !e.target.closest(`#${dropdownId}`)) { - hideDropdown(dropdownId); - } - }); - } - - // ============================================================ - // Status Filter Toggle (Clickable Cards) - // ============================================================ - function toggleStatusFilter(status) { - if (activeStatusFilter === status) { - // Clicking the same card again removes the filter - activeStatusFilter = null; - } else { - // Apply new filter - activeStatusFilter = status; - } - - // Update card styles - updateCardStyles(); - - // Update table title - updateTableTitle(); - - // Reset to page 1 and reload table only (no isLoading guard) - state.page = 1; - loadTableOnly(); - } - - async function loadTableOnly() { - // Cancel any in-flight request to prevent pile-up - if (tableAbortController) { - tableAbortController.abort(); - } - tableAbortController = new AbortController(); - - // Show loading in table container - const container = document.getElementById('tableContainer'); - container.innerHTML = '
Loading...
'; - - // Show refresh indicator - document.getElementById('refreshIndicator').classList.add('active'); - - try { - state.data = await fetchDetail(tableAbortController.signal); - renderSummary(state.data.summary); - renderTable(state.data); - - // Show success indicator - document.getElementById('refreshSuccess').classList.add('active'); - setTimeout(() => { - document.getElementById('refreshSuccess').classList.remove('active'); - }, 1500); - } catch (error) { - // Ignore abort errors (expected when user clicks quickly) - if (error.name === 'AbortError') { - console.log('[WIP Detail] Table request cancelled (new filter selected)'); - return; - } - console.error('Table load failed:', error); - container.innerHTML = '
Error loading data
'; - document.getElementById('refreshError').classList.add('active'); - } finally { - document.getElementById('refreshIndicator').classList.remove('active'); - } - } - - function updateCardStyles() { - const row = document.getElementById('summaryRow'); - const statusCards = document.querySelectorAll('.summary-card.status-run, .summary-card.status-queue, .summary-card.status-quality-hold, .summary-card.status-non-quality-hold'); - - // Remove active from all status cards - statusCards.forEach(card => { - card.classList.remove('active'); - }); - - if (activeStatusFilter) { - // Add filtering class to row (dims non-active cards) - row.classList.add('filtering'); - - // Add active to the selected card - const activeCard = document.querySelector(`.summary-card.status-${activeStatusFilter}`); - if (activeCard) { - activeCard.classList.add('active'); - } - } else { - // Remove filtering class - row.classList.remove('filtering'); - } - } - - function updateTableTitle() { - const titleEl = document.querySelector('.table-title'); - const baseTitle = 'Lot Details'; - - if (activeStatusFilter) { - let statusLabel; - if (activeStatusFilter === 'quality-hold') { - statusLabel = '品質異常 Hold'; - } else if (activeStatusFilter === 'non-quality-hold') { - statusLabel = '非品質異常 Hold'; - } else { - statusLabel = activeStatusFilter.toUpperCase(); - } - titleEl.textContent = `${baseTitle} - ${statusLabel} Only`; - } else { - titleEl.textContent = baseTitle; - } - } - - // ============================================================ - // Filter & Pagination - // ============================================================ - function applyFilters() { - state.filters.workorder = document.getElementById('filterWorkorder').value.trim(); - state.filters.lotid = document.getElementById('filterLotid').value.trim(); - state.filters.package = document.getElementById('filterPackage').value.trim(); - state.filters.type = document.getElementById('filterType').value.trim(); - state.page = 1; - loadAllData(false); - } - - function clearFilters() { - document.getElementById('filterWorkorder').value = ''; - document.getElementById('filterLotid').value = ''; - document.getElementById('filterPackage').value = ''; - document.getElementById('filterType').value = ''; - state.filters = { package: '', type: '', workorder: '', lotid: '' }; - - // Also clear status filter - activeStatusFilter = null; - updateCardStyles(); - updateTableTitle(); - - state.page = 1; - loadAllData(false); - } - - function prevPage() { - if (state.page > 1) { - state.page--; - loadAllData(false); - } - } - - function nextPage() { - if (state.data && state.page < state.data.pagination.total_pages) { - state.page++; - loadAllData(false); - } - } - - // ============================================================ - // Auto-refresh - // ============================================================ - function startAutoRefresh() { - if (state.refreshTimer) { - clearInterval(state.refreshTimer); - } - state.refreshTimer = setInterval(() => { - if (!document.hidden) { - loadAllData(false); - } - }, state.REFRESH_INTERVAL); - } - - function manualRefresh() { - startAutoRefresh(); - loadAllData(false); - } - - // ============================================================ - // Lot Detail Functions - // ============================================================ - let selectedLotId = null; - - async function fetchLotDetail(lotId) { - const result = await MesApi.get(`/api/wip/lot/${encodeURIComponent(lotId)}`, { - timeout: API_TIMEOUT - }); - if (result.success) { - return result.data; - } - throw new Error(result.error || 'Failed to fetch lot detail'); - } - - async function showLotDetail(lotId) { - // Update selected state - selectedLotId = lotId; - - // Highlight the selected row - document.querySelectorAll('.lot-id-link').forEach(el => { - el.classList.toggle('active', el.textContent === lotId); - }); - - // Show panel - const panel = document.getElementById('lotDetailPanel'); - panel.classList.add('show'); - - // Update title - document.getElementById('lotDetailLotId').textContent = lotId; - - // Show loading - document.getElementById('lotDetailContent').innerHTML = ` -
- Loading... -
- `; - - // Scroll to panel - panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); - - try { - const data = await fetchLotDetail(lotId); - renderLotDetail(data); - } catch (error) { - console.error('Failed to load lot detail:', error); - document.getElementById('lotDetailContent').innerHTML = ` -
- 載入失敗:${error.message || '未知錯誤'} -
- `; - } - } - - function renderLotDetail(data) { - const labels = data.fieldLabels || {}; - - // Helper to format value - const formatValue = (value) => { - if (value === null || value === undefined || value === '') { - return '-'; - } - if (typeof value === 'number') { - return formatNumber(value); - } - return value; - }; - - // Helper to create field HTML - const field = (key, customLabel = null) => { - const label = customLabel || labels[key] || key; - const value = data[key]; - let valueClass = ''; - - // Special styling for WIP Status - if (key === 'wipStatus') { - valueClass = `status-${(value || '').toLowerCase()}`; - } - - return ` -
- ${label} - ${formatValue(value)} -
- `; - }; - - const html = ` -
- -
-
基本資訊
- ${field('lotId')} - ${field('workorder')} - ${field('wipStatus')} - ${field('status')} - ${field('qty')} - ${field('qty2')} - ${field('ageByDays')} - ${field('priority')} -
- - -
-
產品資訊
- ${field('product')} - ${field('productLine')} - ${field('packageLef')} - ${field('pjType')} - ${field('pjFunction')} - ${field('bop')} - ${field('dateCode')} - ${field('produceRegion')} -
- - -
-
製程資訊
- ${field('workcenterGroup')} - ${field('workcenter')} - ${field('spec')} - ${field('specSequence')} - ${field('workflow')} - ${field('equipment')} - ${field('equipmentCount')} - ${field('location')} -
- - -
-
物料資訊
- ${field('waferLotId')} - ${field('waferPn')} - ${field('waferLotPrefix')} - ${field('leadframeName')} - ${field('leadframeOption')} - ${field('compoundName')} - ${field('dieConsumption')} - ${field('uts')} -
- - - ${data.wipStatus === 'HOLD' || data.holdCount > 0 ? ` -
-
Hold 資訊
- ${field('holdReason')} - ${field('holdCount')} - ${field('holdEmp')} - ${field('holdDept')} - ${field('holdComment')} - ${field('releaseTime')} - ${field('releaseEmp')} - ${field('releaseComment')} -
- ` : ''} - - - ${data.ncrId ? ` -
-
NCR 資訊
- ${field('ncrId')} - ${field('ncrDate')} -
- ` : ''} - - -
-
備註資訊
- ${field('comment')} - ${field('commentDate')} - ${field('commentEmp')} - ${field('futureHoldComment')} -
- - -
-
其他資訊
- ${field('owner')} - ${field('startDate')} - ${field('tmttRemaining')} - ${field('dataUpdateDate')} -
-
- `; - - document.getElementById('lotDetailContent').innerHTML = html; - } - - function closeLotDetail() { - const panel = document.getElementById('lotDetailPanel'); - panel.classList.remove('show'); - - // Remove highlight from selected row - document.querySelectorAll('.lot-id-link').forEach(el => { - el.classList.remove('active'); - }); - - selectedLotId = null; - } - - // ============================================================ - // Initialize - // ============================================================ - async function init() { - // Setup autocomplete for WORKORDER, LOT ID, PACKAGE, and TYPE - setupAutocomplete('filterWorkorder', 'workorderDropdown', 'workorder'); - setupAutocomplete('filterLotid', 'lotidDropdown', 'lotid'); - setupAutocomplete('filterPackage', 'packageDropdown', 'package'); - setupAutocomplete('filterType', 'typeDropdown', 'type'); - - // Allow Enter key to trigger filter - document.getElementById('filterWorkorder').addEventListener('keypress', (e) => { - if (e.key === 'Enter') applyFilters(); - }); - document.getElementById('filterLotid').addEventListener('keypress', (e) => { - if (e.key === 'Enter') applyFilters(); - }); - document.getElementById('filterPackage').addEventListener('keypress', (e) => { - if (e.key === 'Enter') applyFilters(); - }); - document.getElementById('filterType').addEventListener('keypress', (e) => { - if (e.key === 'Enter') applyFilters(); - }); - - // Get workcenter from URL or use first available - state.workcenter = getUrlParam('workcenter'); - - // Get filters from URL params (passed from wip_overview) - const urlWorkorder = getUrlParam('workorder'); - const urlLotid = getUrlParam('lotid'); - const urlPackage = getUrlParam('package'); - const urlType = getUrlParam('type'); - if (urlWorkorder) { - state.filters.workorder = urlWorkorder; - document.getElementById('filterWorkorder').value = urlWorkorder; - } - if (urlLotid) { - state.filters.lotid = urlLotid; - document.getElementById('filterLotid').value = urlLotid; - } - if (urlPackage) { - state.filters.package = urlPackage; - document.getElementById('filterPackage').value = urlPackage; - } - if (urlType) { - state.filters.type = urlType; - document.getElementById('filterType').value = urlType; - } - - if (!state.workcenter) { - // Fetch workcenters and use first one - try { - const workcenters = await fetchWorkcenters(); - if (workcenters && workcenters.length > 0) { - state.workcenter = workcenters[0].name; - // Update URL without reload - window.history.replaceState({}, '', `/wip-detail?workcenter=${encodeURIComponent(state.workcenter)}`); - } - } catch (error) { - console.error('Failed to fetch workcenters:', error); - } - } - - if (state.workcenter) { - document.getElementById('pageTitle').textContent = `WIP Detail - ${state.workcenter}`; - loadAllData(true); - startAutoRefresh(); - - // Handle page visibility (must be after workcenter is set) - document.addEventListener('visibilitychange', () => { - if (!document.hidden && state.workcenter) { - loadAllData(false); - startAutoRefresh(); - } - }); - } else { - document.getElementById('tableContainer').innerHTML = - '
No workcenter available
'; - document.getElementById('loadingOverlay').style.display = 'none'; - } - } - - window.onload = init; - - Object.assign(window, { - applyFilters, - clearFilters, - toggleStatusFilter, - prevPage, - nextPage, - manualRefresh, - showLotDetail, - closeLotDetail, - init - }); -})(); +createApp(App).mount('#app'); diff --git a/frontend/src/wip-detail/style.css b/frontend/src/wip-detail/style.css new file mode 100644 index 0000000..9f4f69f --- /dev/null +++ b/frontend/src/wip-detail/style.css @@ -0,0 +1,480 @@ +@import '../wip-shared/styles.css'; + +.wip-detail-page .header h1 { + font-size: 24px; +} + +.error-banner { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 6px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; +} + +.filters { + background: var(--card-bg); + padding: 16px 20px; + border-radius: 10px; + margin-bottom: 16px; + box-shadow: var(--shadow); + display: flex; + gap: 16px; + align-items: flex-end; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.filter-group label { + font-size: 12px; + font-weight: 600; + color: var(--muted); +} + +.autocomplete-container { + position: relative; + display: inline-block; +} + +.autocomplete-container input { + width: 180px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; +} + +.autocomplete-container input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.autocomplete-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 200px; + overflow-y: auto; + background: #fff; + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 6px 6px; + box-shadow: var(--shadow); + z-index: 20; + display: none; +} + +.autocomplete-dropdown.show { + display: block; +} + +.autocomplete-item, +.autocomplete-empty { + padding: 8px 12px; + font-size: 13px; +} + +.autocomplete-item { + cursor: pointer; +} + +.autocomplete-item:hover { + background: #f0f2ff; +} + +.autocomplete-empty { + color: var(--muted); +} + +.detail-summary-row { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.detail-summary-row .summary-card.status-run, +.detail-summary-row .summary-card.status-queue, +.detail-summary-row .summary-card.status-quality-hold, +.detail-summary-row .summary-card.status-non-quality-hold { + cursor: pointer; + transition: all 0.2s ease; +} + +.detail-summary-row .summary-card.status-run:hover, +.detail-summary-row .summary-card.status-queue:hover, +.detail-summary-row .summary-card.status-quality-hold:hover, +.detail-summary-row .summary-card.status-non-quality-hold:hover { + transform: translateY(-2px); +} + +.detail-summary-row .summary-card.status-run { + background: #f0fdf4; + border-color: #22c55e; +} + +.detail-summary-row .summary-card.status-queue { + background: #fffbeb; + border-color: #f59e0b; +} + +.detail-summary-row .summary-card.status-quality-hold { + background: #fef2f2; + border-color: #ef4444; +} + +.detail-summary-row .summary-card.status-non-quality-hold { + background: #fff7ed; + border-color: #f97316; +} + +.detail-summary-row .summary-card.status-run .summary-value { + color: #166534; +} + +.detail-summary-row .summary-card.status-queue .summary-value { + color: #92400e; +} + +.detail-summary-row .summary-card.status-quality-hold .summary-value { + color: #991b1b; +} + +.detail-summary-row .summary-card.status-non-quality-hold .summary-value { + color: #9a3412; +} + +.detail-summary-row .summary-card.active { + border-width: 3px; + transform: scale(1.03); +} + +.detail-summary-row.filtering .summary-card.status-run:not(.active), +.detail-summary-row.filtering .summary-card.status-queue:not(.active), +.detail-summary-row.filtering .summary-card.status-quality-hold:not(.active), +.detail-summary-row.filtering .summary-card.status-non-quality-hold:not(.active) { + opacity: 0.5; +} + +.table-section { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.table-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: #fafbfc; +} + +.table-title { + font-size: 16px; + font-weight: 600; + color: var(--text); +} + +.table-info { + font-size: 13px; + color: var(--muted); +} + +.table-container { + overflow-x: auto; + overflow-y: auto; + max-height: 600px; +} + +.table-container table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table-container thead { + position: sticky; + top: 0; + z-index: 10; +} + +.table-container th { + background: #f8f9fa; + padding: 10px 12px; + text-align: left; + border-bottom: 2px solid var(--border); + font-weight: 600; + white-space: nowrap; +} + +.table-container td { + padding: 10px 12px; + border-bottom: 1px solid #f0f0f0; + white-space: nowrap; +} + +.table-container th.fixed-col, +.table-container td.fixed-col { + position: sticky; + background: #fff; + z-index: 5; +} + +.table-container th.fixed-col { + background: #f8f9fa; + z-index: 11; +} + +.table-container th.fixed-col:nth-child(1), +.table-container td.fixed-col:nth-child(1) { + left: 0; + min-width: 150px; +} + +.table-container th.fixed-col:nth-child(2), +.table-container td.fixed-col:nth-child(2) { + left: 150px; + min-width: 100px; +} + +.table-container th.fixed-col:nth-child(3), +.table-container td.fixed-col:nth-child(3) { + left: 250px; + min-width: 120px; +} + +.table-container th.fixed-col:nth-child(4), +.table-container td.fixed-col:nth-child(4) { + left: 370px; + min-width: 100px; + border-right: 2px solid var(--primary); +} + +.table-container tbody tr:hover td { + background: #f8f9fc; +} + +.table-container tbody tr:hover td.fixed-col { + background: #f0f2ff; +} + +.spec-col { + background: #e8ebff; + text-align: center; + font-size: 12px; + min-width: 80px; +} + +.spec-cell { + text-align: center; + font-weight: 600; +} + +.spec-cell.has-data { + background: #d4edda; + color: #155724; +} + +.wip-status-run { + color: #166534; + background: #f0fdf4; + font-weight: 600; +} + +.wip-status-queue { + color: #92400e; + background: #fffbeb; + font-weight: 600; +} + +.wip-status-hold { + color: #991b1b; + background: #fef2f2; + font-weight: 600; +} + +.lot-id-link { + color: var(--primary); + cursor: pointer; + text-decoration: none; + transition: color 0.2s; + background: transparent; + border: none; + padding: 0; + font: inherit; +} + +.lot-id-link:hover { + color: var(--primary-dark); + text-decoration: underline; +} + +.lot-id-link.active { + background: rgba(102, 126, 234, 0.15); + padding: 2px 6px; + border-radius: 4px; +} + +.lot-detail-panel { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + margin-top: 16px; + overflow: hidden; +} + +.lot-detail-panel.show { + display: block; +} + +.lot-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.lot-detail-title { + font-size: 16px; + font-weight: 600; + color: #fff; + display: flex; + align-items: center; + gap: 10px; +} + +.lot-detail-title .lot-id { + background: rgba(255, 255, 255, 0.2); + padding: 4px 12px; + border-radius: 6px; + font-family: monospace; +} + +.lot-detail-close { + background: rgba(255, 255, 255, 0.2); + border: none; + color: #fff; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} + +.lot-detail-close:hover { + background: rgba(255, 255, 255, 0.3); +} + +.lot-detail-content { + padding: 20px; +} + +.lot-detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.lot-detail-section { + background: #f8f9fa; + border-radius: 8px; + padding: 14px; +} + +.lot-detail-section-title { + font-size: 13px; + font-weight: 600; + color: var(--primary); + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 2px solid var(--primary); +} + +.lot-detail-field { + display: flex; + flex-direction: column; + margin-bottom: 10px; +} + +.lot-detail-field:last-child { + margin-bottom: 0; +} + +.lot-detail-label { + font-size: 11px; + color: var(--muted); + margin-bottom: 2px; +} + +.lot-detail-value { + font-size: 13px; + color: var(--text); + word-break: break-word; +} + +.lot-detail-value.status-run { + color: #166534; + font-weight: 600; +} + +.lot-detail-value.status-queue { + color: #92400e; + font-weight: 600; +} + +.lot-detail-value.status-hold { + color: #991b1b; + font-weight: 600; +} + +.lot-detail-loading { + text-align: center; + padding: 40px; + color: var(--muted); +} + +.lot-detail-loading.error { + color: #991b1b; +} + +@media (max-width: 1400px) { + .detail-summary-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .lot-detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1000px) { + .detail-summary-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .filters { + flex-direction: column; + align-items: stretch; + } + + .autocomplete-container, + .autocomplete-container input { + width: 100%; + } + + .detail-summary-row, + .lot-detail-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/wip-overview/App.vue b/frontend/src/wip-overview/App.vue new file mode 100644 index 0000000..57de296 --- /dev/null +++ b/frontend/src/wip-overview/App.vue @@ -0,0 +1,290 @@ + + + diff --git a/frontend/src/wip-overview/components/FilterPanel.vue b/frontend/src/wip-overview/components/FilterPanel.vue new file mode 100644 index 0000000..e403e60 --- /dev/null +++ b/frontend/src/wip-overview/components/FilterPanel.vue @@ -0,0 +1,133 @@ + + + diff --git a/frontend/src/wip-overview/components/MatrixTable.vue b/frontend/src/wip-overview/components/MatrixTable.vue new file mode 100644 index 0000000..e68b5c9 --- /dev/null +++ b/frontend/src/wip-overview/components/MatrixTable.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/wip-overview/components/ParetoSection.vue b/frontend/src/wip-overview/components/ParetoSection.vue new file mode 100644 index 0000000..0464cd0 --- /dev/null +++ b/frontend/src/wip-overview/components/ParetoSection.vue @@ -0,0 +1,186 @@ + + + diff --git a/frontend/src/wip-overview/components/StatusCards.vue b/frontend/src/wip-overview/components/StatusCards.vue new file mode 100644 index 0000000..06a2ed1 --- /dev/null +++ b/frontend/src/wip-overview/components/StatusCards.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/wip-overview/components/SummaryCards.vue b/frontend/src/wip-overview/components/SummaryCards.vue new file mode 100644 index 0000000..99851ad --- /dev/null +++ b/frontend/src/wip-overview/components/SummaryCards.vue @@ -0,0 +1,57 @@ + + + diff --git a/frontend/src/wip-overview/index.html b/frontend/src/wip-overview/index.html new file mode 100644 index 0000000..351aa74 --- /dev/null +++ b/frontend/src/wip-overview/index.html @@ -0,0 +1,12 @@ + + + + + + WIP 即時概況 + + +
+ + + diff --git a/frontend/src/wip-overview/main.js b/frontend/src/wip-overview/main.js index 11a0533..3cb2340 100644 --- a/frontend/src/wip-overview/main.js +++ b/frontend/src/wip-overview/main.js @@ -1,784 +1,6 @@ -import { ensureMesApiAvailable } from '../core/api.js'; -import { - debounce, - fetchWipAutocompleteItems, -} from '../core/autocomplete.js'; -import { - buildWipOverviewQueryParams, - splitHoldByType as splitHoldByTypeShared, - prepareParetoData as prepareParetoDataShared, -} from '../core/wip-derive.js'; +import { createApp } from 'vue'; -ensureMesApiAvailable(); +import App from './App.vue'; +import './style.css'; -(function initWipOverviewPage() { - // ============================================================ - // State Management - // ============================================================ - const state = { - summary: null, - matrix: null, - hold: null, - isLoading: false, - lastError: false, - refreshTimer: null, - REFRESH_INTERVAL: 10 * 60 * 1000, // 10 minutes - filters: { - workorder: '', - lotid: '', - package: '', - type: '' - } - }; - - // Status filter state (null = no filter, 'run'/'queue'/'hold' = filtered) - let activeStatusFilter = null; - - // AbortController for cancelling in-flight requests - let matrixAbortController = null; // For loadMatrixOnly() - let loadAllAbortController = null; // For loadAllData() - - // ============================================================ - // Utility Functions - // ============================================================ - function formatNumber(num) { - if (num === null || num === undefined || num === '-') return '-'; - return num.toLocaleString('zh-TW'); - } - - function updateElementWithTransition(elementId, newValue) { - const el = document.getElementById(elementId); - const oldValue = el.textContent; - let formattedNew; - if (typeof newValue === 'number') { - formattedNew = formatNumber(newValue); - } else if (newValue === null || newValue === undefined) { - formattedNew = '-'; - } else { - formattedNew = newValue; - } - - if (oldValue !== formattedNew) { - el.textContent = formattedNew; - el.classList.add('updated'); - setTimeout(() => el.classList.remove('updated'), 500); - } - } - - function buildQueryParams() { - return buildWipOverviewQueryParams(state.filters); - } - - // ============================================================ - // API Functions (using MesApi) - // ============================================================ - const API_TIMEOUT = 60000; // 60 seconds timeout - - async function fetchSummary(signal = null) { - const params = buildQueryParams(); - const result = await MesApi.get('/api/wip/overview/summary', { - params, - timeout: API_TIMEOUT, - signal - }); - if (result.success) { - return result.data; - } - throw new Error(result.error || 'Failed to fetch summary'); - } - - async function fetchMatrix(signal = null) { - const params = buildWipOverviewQueryParams(state.filters, activeStatusFilter); - const result = await MesApi.get('/api/wip/overview/matrix', { - params, - timeout: API_TIMEOUT, - signal - }); - if (result.success) { - return result.data; - } - throw new Error(result.error || 'Failed to fetch matrix'); - } - - async function fetchHold(signal = null) { - const params = buildQueryParams(); - const result = await MesApi.get('/api/wip/overview/hold', { - params, - timeout: API_TIMEOUT, - signal - }); - if (result.success) { - return result.data; - } - throw new Error(result.error || 'Failed to fetch hold'); - } - - // ============================================================ - // Autocomplete Functions - // ============================================================ - async function searchAutocomplete(type, query) { - const loadingEl = document.getElementById(`${type}Loading`); - loadingEl.classList.add('active'); - try { - return await fetchWipAutocompleteItems({ - searchType: type, - query, - filters: { - workorder: document.getElementById('filterWorkorder').value, - lotid: document.getElementById('filterLotid').value, - package: document.getElementById('filterPackage').value, - type: document.getElementById('filterType').value, - }, - request: (url, options) => MesApi.get(url, options), - }); - } catch (error) { - console.error(`Search ${type} failed:`, error); - } finally { - loadingEl.classList.remove('active'); - } - return []; - } - - function showDropdown(type, items) { - const dropdown = document.getElementById(`${type}Dropdown`); - - if (items.length === 0) { - dropdown.innerHTML = '
無符合結果
'; - } else { - dropdown.innerHTML = items.map(item => - `
${item}
` - ).join(''); - } - dropdown.classList.add('active'); - } - - function hideDropdown(type) { - const dropdown = document.getElementById(`${type}Dropdown`); - dropdown.classList.remove('active'); - } - - function selectAutocomplete(type, value) { - const input = document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`); - input.value = value; - hideDropdown(type); - } - - // Setup autocomplete for inputs - function setupAutocomplete(type) { - const input = document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`); - - const debouncedSearch = debounce(async (query) => { - if (query.length >= 2) { - const items = await searchAutocomplete(type, query); - showDropdown(type, items); - } else { - hideDropdown(type); - } - }, 300); - - input.addEventListener('input', (e) => { - debouncedSearch(e.target.value); - }); - - input.addEventListener('focus', async () => { - const query = input.value; - if (query.length >= 2) { - const items = await searchAutocomplete(type, query); - showDropdown(type, items); - } - }); - - input.addEventListener('blur', () => { - // Delay hide to allow click on dropdown - setTimeout(() => hideDropdown(type), 200); - }); - - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - hideDropdown(type); - applyFilters(); - } - }); - } - - // ============================================================ - // Filter Functions - // ============================================================ - function applyFilters() { - state.filters.workorder = document.getElementById('filterWorkorder').value.trim(); - state.filters.lotid = document.getElementById('filterLotid').value.trim(); - state.filters.package = document.getElementById('filterPackage').value.trim(); - state.filters.type = document.getElementById('filterType').value.trim(); - - updateActiveFiltersDisplay(); - loadAllData(false); - } - - function clearFilters() { - document.getElementById('filterWorkorder').value = ''; - document.getElementById('filterLotid').value = ''; - document.getElementById('filterPackage').value = ''; - document.getElementById('filterType').value = ''; - state.filters.workorder = ''; - state.filters.lotid = ''; - state.filters.package = ''; - state.filters.type = ''; - - updateActiveFiltersDisplay(); - loadAllData(false); - } - - function removeFilter(type) { - document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`).value = ''; - state.filters[type] = ''; - updateActiveFiltersDisplay(); - loadAllData(false); - } - - function updateActiveFiltersDisplay() { - const container = document.getElementById('activeFilters'); - let html = ''; - - if (state.filters.workorder) { - html += `WO: ${state.filters.workorder} ×`; - } - if (state.filters.lotid) { - html += `Lot: ${state.filters.lotid} ×`; - } - if (state.filters.package) { - html += `Pkg: ${state.filters.package} ×`; - } - if (state.filters.type) { - html += `Type: ${state.filters.type} ×`; - } - - container.innerHTML = html; - } - - // ============================================================ - // Render Functions - // ============================================================ - function renderSummary(data) { - if (!data) return; - - updateElementWithTransition('totalLots', data.totalLots); - updateElementWithTransition('totalQty', data.totalQtyPcs); - - const ws = data.byWipStatus || {}; - const runLots = ws.run?.lots; - const runQty = ws.run?.qtyPcs; - const queueLots = ws.queue?.lots; - const queueQty = ws.queue?.qtyPcs; - const qualityHoldLots = ws.qualityHold?.lots; - const qualityHoldQty = ws.qualityHold?.qtyPcs; - const nonQualityHoldLots = ws.nonQualityHold?.lots; - const nonQualityHoldQty = ws.nonQualityHold?.qtyPcs; - - updateElementWithTransition( - 'runLots', - runLots === null || runLots === undefined ? '-' : `${formatNumber(runLots)} lots` - ); - updateElementWithTransition( - 'runQty', - runQty === null || runQty === undefined ? '-' : formatNumber(runQty) - ); - updateElementWithTransition( - 'queueLots', - queueLots === null || queueLots === undefined ? '-' : `${formatNumber(queueLots)} lots` - ); - updateElementWithTransition( - 'queueQty', - queueQty === null || queueQty === undefined ? '-' : formatNumber(queueQty) - ); - updateElementWithTransition( - 'qualityHoldLots', - qualityHoldLots === null || qualityHoldLots === undefined ? '-' : `${formatNumber(qualityHoldLots)} lots` - ); - updateElementWithTransition( - 'qualityHoldQty', - qualityHoldQty === null || qualityHoldQty === undefined ? '-' : formatNumber(qualityHoldQty) - ); - updateElementWithTransition( - 'nonQualityHoldLots', - nonQualityHoldLots === null || nonQualityHoldLots === undefined ? '-' : `${formatNumber(nonQualityHoldLots)} lots` - ); - updateElementWithTransition( - 'nonQualityHoldQty', - nonQualityHoldQty === null || nonQualityHoldQty === undefined ? '-' : formatNumber(nonQualityHoldQty) - ); - - if (data.dataUpdateDate) { - document.getElementById('lastUpdate').textContent = `Last Update: ${data.dataUpdateDate}`; - } - } - - // ============================================================ - // Status Filter Functions - // ============================================================ - function toggleStatusFilter(status) { - if (activeStatusFilter === status) { - // Deactivate filter - activeStatusFilter = null; - } else { - // Activate new filter - activeStatusFilter = status; - } - - updateCardStyles(); - updateMatrixTitle(); - loadMatrixOnly(); - } - - function updateCardStyles() { - const row = document.querySelector('.wip-status-row'); - document.querySelectorAll('.wip-status-card').forEach(card => { - card.classList.remove('active'); - }); - - if (activeStatusFilter) { - row.classList.add('filtering'); - const activeCard = document.querySelector(`.wip-status-card.${activeStatusFilter}`); - if (activeCard) { - activeCard.classList.add('active'); - } - } else { - row.classList.remove('filtering'); - } - } - - function updateMatrixTitle() { - const titleEl = document.querySelector('.card-title'); - if (!titleEl) return; - - const baseTitle = 'Workcenter x Package Matrix (QTY)'; - if (activeStatusFilter) { - let statusLabel; - if (activeStatusFilter === 'quality-hold') { - statusLabel = '品質異常 Hold'; - } else if (activeStatusFilter === 'non-quality-hold') { - statusLabel = '非品質異常 Hold'; - } else { - statusLabel = activeStatusFilter.toUpperCase(); - } - titleEl.textContent = `${baseTitle} - ${statusLabel} Only`; - } else { - titleEl.textContent = baseTitle; - } - } - - async function loadMatrixOnly() { - // Cancel any in-flight matrix request to prevent pile-up - if (matrixAbortController) { - matrixAbortController.abort(); - } - matrixAbortController = new AbortController(); - - const container = document.getElementById('matrixContainer'); - container.innerHTML = '
Loading...
'; - - try { - const matrix = await fetchMatrix(matrixAbortController.signal); - state.matrix = matrix; - renderMatrix(matrix); - } catch (error) { - // Ignore abort errors (expected when user clicks quickly) - if (error.name === 'AbortError') { - console.log('[WIP Overview] Matrix request cancelled (new filter selected)'); - return; - } - console.error('[WIP Overview] Matrix load failed:', error); - container.innerHTML = '
Error loading data
'; - } - } - - function renderMatrix(data) { - const container = document.getElementById('matrixContainer'); - - if (!data || !data.workcenters || data.workcenters.length === 0) { - container.innerHTML = '
No data available
'; - return; - } - - // Limit packages to top 15 for display - const displayPackages = data.packages.slice(0, 15); - - let html = ''; - html += ''; - displayPackages.forEach(pkg => { - html += ``; - }); - html += ''; - html += ''; - - // Data rows - data.workcenters.forEach(wc => { - html += ''; - html += ``; - - displayPackages.forEach(pkg => { - const qty = data.matrix[wc]?.[pkg] || 0; - html += ``; - }); - - html += ``; - html += ''; - }); - - // Total row - html += ''; - html += ''; - displayPackages.forEach(pkg => { - html += ``; - }); - html += ``; - html += ''; - - html += '
Workcenter${pkg}Total
${wc}${qty ? formatNumber(qty) : '-'}${formatNumber(data.workcenter_totals[wc] || 0)}
Total${formatNumber(data.package_totals[pkg] || 0)}${formatNumber(data.grand_total || 0)}
'; - container.innerHTML = html; - } - - // ============================================================ - // Pareto Chart Functions - // ============================================================ - let paretoCharts = { - quality: null, - nonQuality: null - }; - - // Task 2.1: Split hold data by type - function splitHoldByType(data) { - return splitHoldByTypeShared(data); - } - - // Task 2.2: Prepare Pareto data (sort by QTY desc, calculate cumulative %) - function prepareParetoData(items) { - return prepareParetoDataShared(items); - } - - // Task 3.1: Initialize Pareto charts - function initParetoCharts() { - const qualityEl = document.getElementById('qualityParetoChart'); - const nonQualityEl = document.getElementById('nonQualityParetoChart'); - - if (qualityEl && !paretoCharts.quality) { - paretoCharts.quality = echarts.init(qualityEl); - } - if (nonQualityEl && !paretoCharts.nonQuality) { - paretoCharts.nonQuality = echarts.init(nonQualityEl); - } - } - - // Task 3.2: Render Pareto chart with ECharts - function renderParetoChart(chart, paretoData, colorTheme) { - if (!chart) return; - - const barColor = colorTheme === 'quality' ? '#ef4444' : '#f97316'; - const lineColor = colorTheme === 'quality' ? '#991B1B' : '#9A3412'; - - const option = { - tooltip: { - trigger: 'axis', - axisPointer: { type: 'cross' }, - formatter: function(params) { - const reason = params[0].name; - const qty = params[0].value; - const cumPct = params[1] ? params[1].value : 0; - return `${reason}
QTY: ${formatNumber(qty)}
累計: ${cumPct}%`; - } - }, - grid: { - left: '3%', - right: '4%', - bottom: '15%', - top: '10%', - containLabel: true - }, - xAxis: { - type: 'category', - data: paretoData.reasons, - axisLabel: { - rotate: 30, - interval: 0, - fontSize: 10, - formatter: function(value) { - return value.length > 12 ? value.slice(0, 12) + '...' : value; - } - }, - axisTick: { alignWithLabel: true } - }, - yAxis: [ - { - type: 'value', - name: 'QTY', - position: 'left', - axisLabel: { - formatter: function(val) { - return val >= 1000 ? (val / 1000).toFixed(0) + 'k' : val; - } - } - }, - { - type: 'value', - name: '累計%', - position: 'right', - min: 0, - max: 100, - axisLabel: { formatter: '{value}%' } - } - ], - series: [ - { - name: 'QTY', - type: 'bar', - data: paretoData.qtys, - itemStyle: { color: barColor }, - emphasis: { - itemStyle: { color: barColor, opacity: 0.8 } - } - }, - { - name: '累計%', - type: 'line', - yAxisIndex: 1, - data: paretoData.cumulative, - symbol: 'circle', - symbolSize: 6, - lineStyle: { color: lineColor, width: 2 }, - itemStyle: { color: lineColor } - } - ] - }; - - chart.setOption(option); - - // Task 3.3: Add click event for drill-down - chart.off('click'); // Remove existing handlers - chart.on('click', function(params) { - if (params.componentType === 'series' && params.seriesType === 'bar') { - const reason = paretoData.reasons[params.dataIndex]; - if (reason && reason !== '未知') { - window.location.href = `/hold-detail?reason=${encodeURIComponent(reason)}`; - } - } - }); - } - - // Task 4.1 & 4.2: Render Pareto table with drill-down links - function renderParetoTable(containerId, paretoData) { - const container = document.getElementById(containerId); - if (!container) return; - - if (!paretoData.items || paretoData.items.length === 0) { - container.innerHTML = ''; - return; - } - - let html = ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - - paretoData.items.forEach((item, idx) => { - const reason = item.reason || '未知'; - const reasonLink = item.reason - ? `${reason}` - : reason; - html += ''; - html += ``; - html += ``; - html += ``; - html += ``; - html += ''; - }); - - html += '
Hold ReasonLotsQTY累計%
${reasonLink}${formatNumber(item.lots)}${formatNumber(item.qty)}${paretoData.cumulative[idx]}%
'; - container.innerHTML = html; - } - - // Task 3.4: Handle no data state - function showParetoNoData(type, show) { - const chartEl = document.getElementById(`${type}ParetoChart`); - const noDataEl = document.getElementById(`${type}ParetoNoData`); - if (chartEl) chartEl.style.display = show ? 'none' : 'block'; - if (noDataEl) noDataEl.style.display = show ? 'flex' : 'none'; - } - - // Main render function for Hold data - function renderHold(data) { - initParetoCharts(); - - const { quality, nonQuality } = splitHoldByType(data); - const qualityData = prepareParetoData(quality); - const nonQualityData = prepareParetoData(nonQuality); - - // Update counts in header - document.getElementById('qualityHoldCount').textContent = `${quality.length} 項`; - document.getElementById('nonQualityHoldCount').textContent = `${nonQuality.length} 項`; - - // Quality Pareto - if (quality.length > 0) { - showParetoNoData('quality', false); - renderParetoChart(paretoCharts.quality, qualityData, 'quality'); - renderParetoTable('qualityParetoTable', qualityData); - } else { - showParetoNoData('quality', true); - if (paretoCharts.quality) paretoCharts.quality.clear(); - document.getElementById('qualityParetoTable').innerHTML = ''; - } - - // Non-Quality Pareto - if (nonQuality.length > 0) { - showParetoNoData('nonQuality', false); - renderParetoChart(paretoCharts.nonQuality, nonQualityData, 'non-quality'); - renderParetoTable('nonQualityParetoTable', nonQualityData); - } else { - showParetoNoData('nonQuality', true); - if (paretoCharts.nonQuality) paretoCharts.nonQuality.clear(); - document.getElementById('nonQualityParetoTable').innerHTML = ''; - } - } - - // Task 5.3: Window resize handler for charts - window.addEventListener('resize', function() { - if (paretoCharts.quality) paretoCharts.quality.resize(); - if (paretoCharts.nonQuality) paretoCharts.nonQuality.resize(); - }); - - // ============================================================ - // Navigation - // ============================================================ - function navigateToDetail(workcenter) { - const params = new URLSearchParams(); - params.append('workcenter', workcenter); - if (state.filters.workorder) params.append('workorder', state.filters.workorder); - if (state.filters.lotid) params.append('lotid', state.filters.lotid); - if (state.filters.package) params.append('package', state.filters.package); - if (state.filters.type) params.append('type', state.filters.type); - window.location.href = `/wip-detail?${params.toString()}`; - } - - // ============================================================ - // Data Loading - // ============================================================ - async function loadAllData(showOverlay = true) { - // Cancel any in-flight request to prevent connection pile-up - if (loadAllAbortController) { - loadAllAbortController.abort(); - console.log('[WIP Overview] Previous request cancelled'); - } - loadAllAbortController = new AbortController(); - const signal = loadAllAbortController.signal; - - state.isLoading = true; - console.log('[WIP Overview] Loading data...', showOverlay ? '(with overlay)' : '(background)'); - - if (showOverlay) { - document.getElementById('loadingOverlay').style.display = 'flex'; - } - - // Show refresh indicator - document.getElementById('refreshIndicator').classList.add('active'); - document.getElementById('refreshError').classList.remove('active'); - document.getElementById('refreshSuccess').classList.remove('active'); - - try { - const startTime = performance.now(); - const [summary, matrix, hold] = await Promise.all([ - fetchSummary(signal), - fetchMatrix(signal), - fetchHold(signal) - ]); - const elapsed = Math.round(performance.now() - startTime); - - state.summary = summary; - state.matrix = matrix; - state.hold = hold; - state.lastError = false; - - renderSummary(summary); - renderMatrix(matrix); - renderHold(hold); - - console.log(`[WIP Overview] Data loaded successfully in ${elapsed}ms`); - - // Show success indicator - document.getElementById('refreshSuccess').classList.add('active'); - setTimeout(() => { - document.getElementById('refreshSuccess').classList.remove('active'); - }, 1500); - - } catch (error) { - // Ignore abort errors (expected when user triggers new request) - if (error.name === 'AbortError') { - console.log('[WIP Overview] Request cancelled (new request started)'); - return; - } - console.error('[WIP Overview] Data load failed:', error); - state.lastError = true; - document.getElementById('refreshError').classList.add('active'); - } finally { - state.isLoading = false; - document.getElementById('loadingOverlay').style.display = 'none'; - document.getElementById('refreshIndicator').classList.remove('active'); - } - } - - // ============================================================ - // Auto-refresh - // ============================================================ - function startAutoRefresh() { - if (state.refreshTimer) { - clearInterval(state.refreshTimer); - } - console.log('[WIP Overview] Auto-refresh started, interval:', state.REFRESH_INTERVAL / 1000, 'seconds'); - state.refreshTimer = setInterval(() => { - if (!document.hidden) { - console.log('[WIP Overview] Auto-refresh triggered at', new Date().toLocaleTimeString()); - loadAllData(false); // Don't show overlay for auto-refresh - } else { - console.log('[WIP Overview] Auto-refresh skipped (tab hidden)'); - } - }, state.REFRESH_INTERVAL); - } - - function manualRefresh() { - // Reset timer on manual refresh - startAutoRefresh(); - loadAllData(false); - } - - // Handle page visibility - document.addEventListener('visibilitychange', () => { - if (!document.hidden) { - // Page became visible - refresh immediately - loadAllData(false); - startAutoRefresh(); - } - }); - - // ============================================================ - // Initialize - // ============================================================ - window.onload = function() { - setupAutocomplete('workorder'); - setupAutocomplete('lotid'); - setupAutocomplete('package'); - setupAutocomplete('type'); - loadAllData(true); - startAutoRefresh(); - }; - - Object.assign(window, { - applyFilters, - clearFilters, - toggleStatusFilter, - selectAutocomplete, - removeFilter, - navigateToDetail, - manualRefresh, - loadAllData, - startAutoRefresh - }); -})(); +createApp(App).mount('#app'); diff --git a/frontend/src/wip-overview/style.css b/frontend/src/wip-overview/style.css new file mode 100644 index 0000000..d1a6a6f --- /dev/null +++ b/frontend/src/wip-overview/style.css @@ -0,0 +1,499 @@ +@import '../wip-shared/styles.css'; + +.wip-overview-page .header h1 { + font-size: 24px; +} + +.error-banner { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 6px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; +} + +.filters { + background: var(--card-bg); + padding: 16px 20px; + border-radius: 10px; + margin-bottom: 16px; + box-shadow: var(--shadow); + display: flex; + gap: 16px; + align-items: flex-end; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 6px; + position: relative; +} + +.filter-group label { + font-size: 12px; + font-weight: 600; + color: var(--muted); +} + +.filter-group input { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; + min-width: 200px; +} + +.filter-group input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.search-loading { + position: absolute; + right: 10px; + top: 32px; + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + display: none; +} + +.search-loading.active { + display: block; +} + +.autocomplete-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: var(--shadow); + max-height: 200px; + overflow-y: auto; + z-index: 20; + display: none; +} + +.autocomplete-dropdown.active { + display: block; +} + +.autocomplete-item { + padding: 8px 12px; + cursor: pointer; + font-size: 13px; +} + +.autocomplete-item:hover { + background: #f3f4f6; +} + +.autocomplete-item.no-results { + color: var(--muted); + cursor: default; +} + +.active-filters { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.filter-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: #e8ecff; + color: var(--primary); + border-radius: 4px; + font-size: 12px; +} + +.filter-tag .remove { + cursor: pointer; + font-weight: 700; +} + +.overview-summary-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.wip-status-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 16px; +} + +.wip-status-card { + background: var(--card-bg); + border-radius: 10px; + padding: 12px 16px; + border: 2px solid; + box-shadow: var(--shadow); + cursor: pointer; + transition: all 0.2s ease; +} + +.wip-status-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); +} + +.wip-status-card .status-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +} + +.wip-status-card .status-header .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.wip-status-card .status-values { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 24px; +} + +.wip-status-card .status-values span { + font-size: 24px; + font-weight: 700; + color: var(--text); +} + +.wip-status-card.run { + background: #f0fdf4; + border-color: #22c55e; +} + +.wip-status-card.queue { + background: #fffbeb; + border-color: #f59e0b; +} + +.wip-status-card.quality-hold { + background: #fef2f2; + border-color: #ef4444; +} + +.wip-status-card.non-quality-hold { + background: #fff7ed; + border-color: #f97316; +} + +.wip-status-card.run .status-header { + color: #166534; +} + +.wip-status-card.queue .status-header { + color: #92400e; +} + +.wip-status-card.quality-hold .status-header { + color: #991b1b; +} + +.wip-status-card.non-quality-hold .status-header { + color: #9a3412; +} + +.wip-status-card.active { + border-width: 4px; + transform: scale(1.03); +} + +.wip-status-card.run.active { + background: #dcfce7; + box-shadow: 0 6px 25px rgba(34, 197, 94, 0.5); +} + +.wip-status-card.queue.active { + background: #fef3c7; + box-shadow: 0 6px 25px rgba(245, 158, 11, 0.5); +} + +.wip-status-card.quality-hold.active { + background: #fee2e2; + box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5); +} + +.wip-status-card.non-quality-hold.active { + background: #ffedd5; + box-shadow: 0 6px 25px rgba(249, 115, 22, 0.5); +} + +.wip-status-row.filtering .wip-status-card:not(.active) { + opacity: 0.5; +} + +.content-grid { + display: flex; + flex-direction: column; + gap: 16px; +} + +.card { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.card-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: #fafbfc; +} + +.card-title { + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +.card-body { + padding: 16px; + overflow-x: auto; +} + +.card-body.matrix-container { + padding: 0; + max-height: 500px; + overflow: auto; +} + +.matrix-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.matrix-table th, +.matrix-table td { + padding: 8px 10px; + text-align: right; + border: 1px solid #e5e7eb; + white-space: nowrap; +} + +.matrix-table th { + background: #f3f4f6; + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; + border-bottom: 2px solid #cbd5e1; +} + +.matrix-table th:first-child { + text-align: left; + position: sticky; + left: 0; + top: 0; + z-index: 3; + background: #e5e7eb; + border-right: 2px solid #cbd5e1; +} + +.matrix-table td:first-child { + text-align: left; + font-weight: 600; + position: sticky; + left: 0; + background: #f9fafb; + z-index: 1; + border-right: 2px solid #cbd5e1; +} + +.matrix-table tbody tr:hover td { + background: #f0f4ff; +} + +.matrix-table tbody tr:hover td:first-child { + background: #e8ecff; +} + +.matrix-table .total-row td, +.matrix-table .total-col { + background: #e5e7eb; + font-weight: 700; +} + +.matrix-table .clickable { + cursor: pointer; + color: var(--primary); +} + +.matrix-table .clickable:hover { + text-decoration: underline; +} + +.pareto-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.pareto-section { + background: var(--card-bg); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.pareto-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: #fafbfc; +} + +.pareto-header.quality { + background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); + border-bottom-color: #fca5a5; +} + +.pareto-header.non-quality { + background: linear-gradient(135deg, #ffedd5 0%, #fed7aa 100%); + border-bottom-color: #fdba74; +} + +.pareto-title { + font-size: 14px; + font-weight: 600; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; +} + +.pareto-title .badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.1); +} + +.pareto-body { + padding: 12px; +} + +.pareto-chart { + width: 100%; + height: 360px; +} + +.pareto-no-data { + display: flex; + align-items: center; + justify-content: center; + height: 280px; + color: var(--muted); + font-size: 14px; +} + +.pareto-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + margin-top: 12px; +} + +.pareto-table th, +.pareto-table td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid #e5e7eb; +} + +.pareto-table th { + background: #f9fafb; + font-weight: 600; + font-size: 11px; + color: var(--muted); + text-transform: uppercase; +} + +.pareto-table td:nth-child(2), +.pareto-table td:nth-child(3), +.pareto-table td:nth-child(4), +.pareto-table th:nth-child(2), +.pareto-table th:nth-child(3), +.pareto-table th:nth-child(4) { + text-align: right; +} + +.pareto-table tbody tr:hover { + background: #f3f4f6; +} + +.pareto-table .reason-link { + color: var(--primary); + text-decoration: none; + cursor: pointer; +} + +.pareto-table .reason-link:hover { + text-decoration: underline; + color: var(--primary-dark); +} + +.pareto-table .cumulative { + color: var(--muted); + font-size: 11px; +} + +@media (max-width: 1200px) { + .pareto-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1000px) { + .filters { + gap: 12px; + } + + .filter-group input { + min-width: 180px; + } + + .wip-status-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .filters { + flex-direction: column; + align-items: stretch; + } + + .filter-group input { + width: 100%; + min-width: 0; + } + + .overview-summary-row, + .wip-status-row { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/wip-shared/components/Pagination.vue b/frontend/src/wip-shared/components/Pagination.vue new file mode 100644 index 0000000..e3101a5 --- /dev/null +++ b/frontend/src/wip-shared/components/Pagination.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/wip-shared/composables/useAutoRefresh.js b/frontend/src/wip-shared/composables/useAutoRefresh.js new file mode 100644 index 0000000..e3c6edb --- /dev/null +++ b/frontend/src/wip-shared/composables/useAutoRefresh.js @@ -0,0 +1,99 @@ +import { onBeforeUnmount, onMounted } from 'vue'; + +const DEFAULT_REFRESH_INTERVAL_MS = 10 * 60 * 1000; + +export function useAutoRefresh({ + onRefresh, + intervalMs = DEFAULT_REFRESH_INTERVAL_MS, + autoStart = true, + refreshOnVisible = true, +} = {}) { + let refreshTimer = null; + const controllers = new Map(); + + function stopAutoRefresh() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } + + function startAutoRefresh() { + stopAutoRefresh(); + refreshTimer = setInterval(() => { + if (!document.hidden) { + void onRefresh?.(); + } + }, intervalMs); + } + + function resetAutoRefresh() { + startAutoRefresh(); + } + + function createAbortSignal(key = 'default') { + const previous = controllers.get(key); + if (previous) { + previous.abort(); + } + + const controller = new AbortController(); + controllers.set(key, controller); + return controller.signal; + } + + function clearAbortController(key = 'default') { + const controller = controllers.get(key); + if (controller) { + controller.abort(); + controllers.delete(key); + } + } + + function abortAllRequests() { + controllers.forEach((controller) => { + controller.abort(); + }); + controllers.clear(); + } + + async function triggerRefresh({ force = false, resetTimer = false } = {}) { + if (!force && document.hidden) { + return; + } + if (resetTimer) { + resetAutoRefresh(); + } + await onRefresh?.(); + } + + function handleVisibilityChange() { + if (!refreshOnVisible || document.hidden) { + return; + } + void triggerRefresh({ force: true, resetTimer: true }); + } + + onMounted(() => { + if (autoStart) { + startAutoRefresh(); + } + document.addEventListener('visibilitychange', handleVisibilityChange); + }); + + onBeforeUnmount(() => { + stopAutoRefresh(); + abortAllRequests(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }); + + return { + startAutoRefresh, + stopAutoRefresh, + resetAutoRefresh, + createAbortSignal, + clearAbortController, + abortAllRequests, + triggerRefresh, + }; +} diff --git a/frontend/src/wip-shared/composables/useAutocomplete.js b/frontend/src/wip-shared/composables/useAutocomplete.js new file mode 100644 index 0000000..162beac --- /dev/null +++ b/frontend/src/wip-shared/composables/useAutocomplete.js @@ -0,0 +1,132 @@ +import { reactive } from 'vue'; + +import { debounce, fetchWipAutocompleteItems } from '../../core/autocomplete.js'; +import { apiGet } from '../../core/api.js'; + +function createFieldState() { + return { + query: '', + items: [], + loading: false, + open: false, + }; +} + +export function useAutocomplete({ + getFilters = () => ({}), + request = (url, options) => apiGet(url, options), + debounceMs = 300, + minChars = 2, +} = {}) { + const fields = reactive({}); + const debouncedSearchers = new Map(); + + function ensureField(type) { + if (!fields[type]) { + fields[type] = createFieldState(); + } + return fields[type]; + } + + async function search(type, rawQuery) { + const field = ensureField(type); + const query = String(rawQuery ?? '').trim(); + + if (query.length < minChars) { + field.loading = false; + field.items = []; + field.open = false; + return []; + } + + field.loading = true; + const items = await fetchWipAutocompleteItems({ + searchType: type, + query, + filters: getFilters(), + request, + }); + + field.items = Array.isArray(items) ? items : []; + field.open = true; + field.loading = false; + return field.items; + } + + function getDebouncedSearcher(type) { + if (!debouncedSearchers.has(type)) { + debouncedSearchers.set( + type, + debounce((query) => { + void search(type, query); + }, debounceMs) + ); + } + return debouncedSearchers.get(type); + } + + function handleInput(type, value) { + const field = ensureField(type); + field.query = value; + + if (String(value ?? '').trim().length < minChars) { + field.open = false; + field.items = []; + return; + } + + getDebouncedSearcher(type)(value); + } + + function handleFocus(type) { + const field = ensureField(type); + if (String(field.query ?? '').trim().length >= minChars) { + void search(type, field.query); + } + } + + function handleBlur(type, delayMs = 200) { + setTimeout(() => { + const field = ensureField(type); + field.open = false; + }, delayMs); + } + + function selectItem(type, value) { + const field = ensureField(type); + field.query = value; + field.open = false; + return value; + } + + function setValue(type, value) { + const field = ensureField(type); + field.query = value ?? ''; + } + + function clearField(type) { + const field = ensureField(type); + field.query = ''; + field.items = []; + field.open = false; + } + + function hideAll() { + Object.keys(fields).forEach((key) => { + fields[key].open = false; + }); + } + + return { + fields, + ensureField, + search, + handleInput, + handleFocus, + handleBlur, + selectItem, + setValue, + clearField, + hideAll, + }; +} diff --git a/frontend/src/wip-shared/constants.js b/frontend/src/wip-shared/constants.js new file mode 100644 index 0000000..0b7a281 --- /dev/null +++ b/frontend/src/wip-shared/constants.js @@ -0,0 +1,15 @@ +export const NON_QUALITY_HOLD_REASONS = Object.freeze([ + 'IQC檢驗(久存品驗證)(QC)', + '大中/安波幅50pcs樣品留樣(PD)', + '工程驗證(PE)', + '工程驗證(RD)', + '指定機台生產', + '特殊需求(X-Ray全檢)', + '特殊需求管控', + '第一次量產QC品質確認(QC)', + '需綁尾數(PD)', + '樣品需求留存打樣(樣品)', + '盤點(收線)需求', +]); + +export const NON_QUALITY_HOLD_REASON_SET = new Set(NON_QUALITY_HOLD_REASONS); diff --git a/frontend/src/wip-shared/styles.css b/frontend/src/wip-shared/styles.css new file mode 100644 index 0000000..a74ce2e --- /dev/null +++ b/frontend/src/wip-shared/styles.css @@ -0,0 +1,347 @@ +:root { + --bg: #f5f7fa; + --card-bg: #ffffff; + --text: #222; + --muted: #666; + --border: #e2e6ef; + --primary: #667eea; + --primary-dark: #5568d3; + --shadow: 0 2px 10px rgba(0, 0, 0, 0.08); + --shadow-strong: 0 4px 15px rgba(102, 126, 234, 0.2); + --success: #22c55e; + --danger: #ef4444; + --warning: #f59e0b; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Microsoft JhengHei', Arial, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.dashboard { + max-width: 1900px; + margin: 0 auto; + padding: 20px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 18px 22px; + background: var(--header-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%)); + border-radius: 10px; + margin-bottom: 16px; + box-shadow: var(--shadow-strong); +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.header h1 { + color: #fff; + font-size: 24px; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.last-update { + color: rgba(255, 255, 255, 0.85); + font-size: 13px; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.refresh-indicator { + display: none; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.refresh-indicator.active { + display: inline-block; +} + +.refresh-success { + color: #22c55e; + display: none; +} + +.refresh-success.active { + display: inline-block; + animation: fadeOut 1s ease-out forwards; +} + +.refresh-error { + width: 8px; + height: 8px; + background: var(--danger); + border-radius: 50%; + display: none; +} + +.refresh-error.active { + display: inline-block; +} + +.btn { + padding: 9px 20px; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-light { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.btn-light:hover { + background: rgba(255, 255, 255, 0.3); +} + +.btn-back { + background: rgba(255, 255, 255, 0.15); + color: #fff; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn-back:hover { + background: rgba(255, 255, 255, 0.25); +} + +.btn-primary, +.btn-secondary { + padding: 9px 20px; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-secondary { + background: #6c757d; + color: #fff; +} + +.btn-secondary:hover { + background: #5a6268; +} + +.summary-row { + display: grid; + gap: 14px; + margin-bottom: 16px; +} + +.summary-card { + background: var(--card-bg); + border-radius: 10px; + padding: 16px 20px; + text-align: center; + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.summary-label { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.summary-value { + font-size: 28px; + font-weight: 700; + color: var(--primary); + transition: all 0.3s ease; +} + +.summary-value.updated { + animation: valueUpdate 0.5s ease; +} + +.summary-value.small { + font-size: 20px; +} + +.filter-indicator { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--primary); + background: #e8ecff; + padding: 4px 12px; + border-radius: 4px; +} + +.filter-indicator .clear-btn { + cursor: pointer; + font-weight: 700; +} + +.filter-indicator .clear-btn:hover { + color: var(--danger); +} + +.placeholder { + text-align: center; + padding: 40px 20px; + color: var(--muted); +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding: 16px; + border-top: 1px solid var(--border); +} + +.pagination button { + padding: 8px 16px; + border: 1px solid var(--border); + background: #fff; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} + +.pagination button:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); +} + +.pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination .page-info { + font-size: 13px; + color: var(--muted); +} + +.loading-overlay { + position: fixed; + inset: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.loading-spinner { + display: inline-block; + width: 24px; + height: 24px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 10px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 70% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@keyframes valueUpdate { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + background: rgba(102, 126, 234, 0.1); + } + 100% { + transform: scale(1); + } +} + +@media (max-width: 1400px) { + .summary-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 1000px) { + .summary-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .dashboard { + padding: 16px; + } + + .summary-row { + grid-template-columns: 1fr; + } + + .header { + padding: 14px 16px; + } + + .header h1 { + font-size: 20px; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index cf0eed7..10f8df1 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -13,9 +13,9 @@ export default defineConfig(({ mode }) => ({ rollupOptions: { input: { portal: resolve(__dirname, 'src/portal/main.js'), - 'wip-overview': resolve(__dirname, 'src/wip-overview/main.js'), - 'wip-detail': resolve(__dirname, 'src/wip-detail/main.js'), - 'hold-detail': resolve(__dirname, 'src/hold-detail/main.js'), + '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'), 'job-query': resolve(__dirname, 'src/job-query/main.js'), diff --git a/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/.openspec.yaml b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/.openspec.yaml new file mode 100644 index 0000000..9bc4ae2 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-09 diff --git a/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/design.md b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/design.md new file mode 100644 index 0000000..e124c42 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/design.md @@ -0,0 +1,127 @@ +## Context + +WIP 三頁(Overview、Detail、Hold Detail)是報表類核心頁面,合計 1,941 行 vanilla JS,目前透過 Jinja2 `_base.html` 載入 `window.MesApi` 和 `window.Toast` 全域物件。QC-GATE 和 Tables 頁面已成功遷移至純 Vite 架構,建立了 `send_from_directory` + `apiGet`/`apiPost` 模式。 + +三頁存在 drill-down 導覽關係: +- Overview → Detail:點擊 matrix workcenter,透過 URL params 傳遞 `workcenter` + 四個篩選條件 +- Overview → Hold Detail:點擊 Pareto 柱/表格連結,透過 `?reason=` 傳遞 + +所有後端 API 均為 GET,無 CSRF 依賴,後端路由和 API 不需修改。 + +## Goals / Non-Goals + +**Goals:** +- 三頁完全脫離 Jinja2 模板和 `window.MesApi` 依賴 +- 保持現有功能完全一致(pixel-level 不要求,行為一致即可) +- 建立三頁共用的 CSS 變數和基礎樣式模組 +- Hold Detail 新增自動刷新 + AbortController(與另兩頁一致) +- 保持三頁之間的 drill-down 導覽正常運作 + +**Non-Goals:** +- 不引入 Vue Router SPA 架構(三頁仍為獨立 HTML entry) +- 不引入 Pinia 狀態管理(composable 足夠) +- 不重構後端 API 或資料結構 +- 不改變 portal iframe 嵌入機制 +- 不引入 vue-echarts 套件(直接使用 echarts API,與 QC-GATE 模式一致) + +## Decisions + +### D1: 三頁保持獨立 entry(不合併為 SPA) + +**選擇**:每頁獨立 `index.html` entry point,沿用 QC-GATE/Tables 模式。 + +**替代方案**:Vue Router SPA 將三頁合併為一個 entry。 + +**理由**: +- 三頁在 portal iframe 中各自獨立載入,SPA 無法帶來路由切換加速 +- 獨立 entry 與既有遷移模式一致,降低風險 +- 各頁 bundle 獨立,不會因一頁改動影響其他頁面的快取 + +### D2: Hold Detail 的 hold_type 判斷移至前端 + +**選擇**:在前端維護 `NON_QUALITY_HOLD_REASONS` 常數集合(11 個值),從 URL `?reason=` 讀取後在前端判斷。 + +**替代方案**:新增 API endpoint 回傳 hold_type 分類。 + +**理由**: +- 集合很小且穩定(11 個非品質原因值) +- 避免新增 API 的維護成本 +- Summary API 已回傳足夠資訊,無需額外 round-trip +- 若未來集合需要動態管理,可改為從 config API 載入 + +### D3: 共用 CSS 提取為 `wip-shared.css` + +**選擇**:建立 `frontend/src/wip-shared/styles.css`,包含三頁共用的 `:root` 變數、gradient header、loading overlay、card、button、pagination、responsive breakpoints。各頁 `style.css` 只包含頁面特有樣式,透過 `@import` 引入共用樣式。 + +**替代方案**:每頁複製一份完整 CSS。 + +**理由**: +- 三頁 CSS 基底高度重複(`:root` 變數、header、loading overlay、summary card 完全相同) +- 減少維護成本,修改一處即可影響三頁 +- Vite 會將 `@import` 合併到各頁 bundle,不增加 HTTP 請求 + +### D4: Autocomplete 提取為共用 Vue composable + +**選擇**:建立 `frontend/src/wip-shared/composables/useAutocomplete.js`,封裝 debounce 搜尋 + cross-filter + dropdown 狀態。Overview 和 Detail 共用。 + +**理由**: +- 兩頁的 autocomplete 邏輯幾乎相同(4 個欄位、cross-filter、debounce 300ms) +- 現有 `core/autocomplete.js` 提供底層函式,composable 封裝 Vue 反應式狀態 +- Hold Detail 不使用 autocomplete,不受影響 + +### D5: ECharts 直接使用(不引入 vue-echarts) + +**選擇**:與 QC-GATE 一致,在 Vue 元件中直接使用 `echarts.init()` + `onMounted`/`onUnmounted` 管理生命週期。 + +**理由**: +- QC-GATE 已建立此模式且運作良好 +- 避免引入額外依賴 +- Pareto 圖 click 事件需要 drill-down 到 `/hold-detail`,直接操作更靈活 + +### D6: Hold Detail Flask route 保持 server-side redirect + +**選擇**:`/hold-detail` route 保留 server-side `reason` 參數驗證(缺少時 redirect 到 `/wip-overview`),驗證通過後 `send_from_directory` 回傳靜態 HTML。 + +**替代方案**:完全移除 server-side 驗證,在前端處理缺少 reason 的情況。 + +**理由**: +- 保持與現有行為一致(無 reason 時不顯示空白頁面) +- Server-side redirect 比前端 `window.location` 更快 +- Blueprint 路由只需小幅修改 + +### D7: 元件拆分策略 + +**WIP Overview(App.vue + 5 元件):** +- `FilterPanel.vue`:4 個 autocomplete 輸入 + filter tags +- `SummaryCards.vue`:2 個 KPI 卡片 +- `StatusCards.vue`:4 個可點擊狀態卡片 +- `MatrixTable.vue`:Workcenter × Package 交叉表 +- `ParetoSection.vue`:Pareto 圖 + 明細表(品質/非品質各一個實例) + +**WIP Detail(App.vue + 5 元件):** +- `FilterPanel.vue`:4 個 autocomplete 輸入(與 Overview 共用 composable) +- `SummaryCards.vue`:5 個狀態 KPI 卡片 +- `LotTable.vue`:sticky 欄位 + spec 動態欄 + 分頁 +- `LotDetailPanel.vue`:inline 展開式 lot 明細面板 +- `Pagination.vue`:分頁控制(可與 Hold Detail 共用) + +**Hold Detail(App.vue + 5 元件):** +- `SummaryCards.vue`:5 個 KPI 卡片 +- `AgeDistribution.vue`:4 個可點擊 age 卡片 +- `DistributionTable.vue`:Workcenter/Package 分佈表(2 個實例) +- `LotTable.vue`:10 欄 lot 明細表 + filter indicator +- `Pagination.vue`:分頁控制 + +## Risks / Trade-offs + +**[Hold Detail reason redirect] → 前端 fallback** +`send_from_directory` 回傳靜態 HTML 後,前端需在 `onMounted` 中檢查 `URLSearchParams` 是否有 `reason`,若無則 `window.location.href = '/wip-overview'`。Server-side redirect 只在直接 URL 存取時作用,iframe 載入時也需要前端保護。 + +**[CSS 提取可能遺漏] → 逐頁驗證** +三頁 CSS 雖然高度重複但並非完全相同(如 Hold Detail header 用動態顏色、Detail 有 sticky column 樣式)。提取共用部分時需逐頁視覺驗證,確保沒有遺漏特有樣式。 + +**[NON_QUALITY_HOLD_REASONS 同步] → 單一來源** +前端維護的 11 個非品質原因值必須與後端 `sql/filters.py` 的 `NON_QUALITY_HOLD_REASONS` 保持一致。可在 `wip-shared/constants.js` 中建立,並在 code review 時交叉比對。 + +**[三頁同時遷移範圍較大] → 逐頁推進** +1,941 行一次遷移風險較高。實作順序建議:Hold Detail(最簡單 336 行) → Overview(核心頁面 784 行) → Detail(最複雜 821 行),每頁完成後即可獨立測試。 diff --git a/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/proposal.md b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/proposal.md new file mode 100644 index 0000000..9e82683 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/proposal.md @@ -0,0 +1,35 @@ +## Why + +WIP 三頁(Overview、Detail、Hold Detail)是目前使用量最高的報表頁面,仍依賴 Jinja2 模板 + vanilla JS 架構。三頁有 drill-down 導覽依賴關係,必須作為一個整體遷移至 Vue 3 + Vite 純前端架構,以統一前端技術棧並消除 `_base.html` / `window.MesApi` 依賴。QC-GATE 和 Tables 頁面已成功建立遷移模式,現在是批量套用此模式的時機。 + +## What Changes + +- 將 `/wip-overview`(784 行 vanilla JS)重寫為 Vue 3 SFC 元件,包含 ECharts Pareto 圖、autocomplete 篩選、狀態卡片矩陣互動 +- 將 `/wip-detail`(821 行 vanilla JS)重寫為 Vue 3 SFC 元件,包含 4 sticky 欄位表格、動態 spec 欄、inline lot detail panel +- 將 `/hold-detail`(336 行 vanilla JS)重寫為 Vue 3 SFC 元件,包含 age/workcenter/package 三維篩選 +- 三頁 Vite entry 從 `main.js` 改為 `index.html`,Flask route 從 `render_template` 改為 `send_from_directory` +- 刪除三個 Jinja2 模板(`wip_overview.html`、`wip_detail.html`、`hold_detail.html`) +- Hold Detail 移除 Jinja2 server-side 注入(`reason`、`hold_type`),改為前端 URL params + 常數判斷 +- Hold Detail 新增 10 分鐘自動刷新 + AbortController(與 Overview/Detail 一致) +- 提取三頁共用 CSS 變數與基礎樣式為共用模組 +- 所有 `window.MesApi.get()` 呼叫改為 `apiGet()` from `core/api.js` + +## Capabilities + +### New Capabilities +- `wip-overview-page`: WIP Overview 頁面的功能需求(summary、matrix、hold pareto、autocomplete 篩選、狀態卡片互動、drill-down 導覽) +- `wip-detail-page`: WIP Detail 頁面的功能需求(workcenter lot 明細、sticky 欄位表格、spec 動態欄、inline lot detail panel、autocomplete 篩選、狀態卡片互動) +- `hold-detail-page`: Hold Detail 頁面的功能需求(hold reason 分析、age/workcenter/package 三維篩選、分頁 lot 明細) + +### Modified Capabilities +- `vue-vite-page-architecture`: 新增三頁的 Vite entry 與 chunk splitting 規則,擴展 ECharts 共用 chunk 至 Overview 頁面 + +## Impact + +- **前端**:`frontend/src/wip-overview/`、`frontend/src/wip-detail/`、`frontend/src/hold-detail/` 目錄結構重組為 Vue 3 SFC +- **Vite 配置**:`vite.config.js` 三個 entry 從 `main.js` 改為 `index.html` +- **Flask 路由**:`app.py` 中 `/wip-overview`、`/wip-detail` 改為 `send_from_directory`;`hold_routes.py` 中 `/hold-detail` 改為 `send_from_directory`(需保留 reason 驗證邏輯改為 API 層) +- **模板刪除**:`templates/wip_overview.html`、`templates/wip_detail.html`、`templates/hold_detail.html` +- **共用模組**:`core/wip-derive.js`、`core/autocomplete.js` 保持不變(已為 ES module);`core/table-tree.js` 的 `escapeHtml` 在 Vue 中不再需要 +- **建置腳本**:`package.json` build script 需 copy 三個新 HTML 檔案 +- **後端 API**:所有 API endpoint 不變,僅 `/hold-detail` 頁面路由變更 diff --git a/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/hold-detail-page/spec.md b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/hold-detail-page/spec.md new file mode 100644 index 0000000..bdcb26a --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/hold-detail-page/spec.md @@ -0,0 +1,115 @@ +## ADDED Requirements + +### Requirement: Hold Detail page SHALL display hold reason analysis +The page SHALL show summary statistics for a specific hold reason. + +#### Scenario: Summary cards rendering +- **WHEN** the page loads with `?reason={reason}` in the URL +- **THEN** the page SHALL call `GET /api/wip/hold-detail/summary` with the reason +- **THEN** five cards SHALL display: Total Lots, Total QTY, 平均當站滯留, 最久當站滯留, 影響站群 +- **THEN** age values SHALL display with "天" suffix + +#### Scenario: Hold type classification +- **WHEN** the page loads +- **THEN** the header gradient color SHALL be red (#ef4444) for quality holds or orange (#f97316) for non-quality holds +- **THEN** a badge SHALL display "品質異常" or "非品質異常" accordingly +- **THEN** classification SHALL use a frontend `NON_QUALITY_HOLD_REASONS` constant set (11 values matching backend `sql/filters.py`) + +#### Scenario: Missing reason parameter +- **WHEN** the page loads without a `reason` URL parameter +- **THEN** the page SHALL redirect to `/wip-overview` + +### Requirement: Hold Detail page SHALL display age distribution +The page SHALL show the distribution of hold lots by age at current station. + +#### Scenario: Age distribution cards +- **WHEN** distribution data is loaded from `GET /api/wip/hold-detail/distribution` +- **THEN** four clickable cards SHALL display: 0-1天, 1-3天, 3-7天, 7+天 +- **THEN** each card SHALL show Lots, QTY, and percentage + +#### Scenario: Age card click filters lots +- **WHEN** user clicks an age card +- **THEN** the lot table SHALL reload filtered to that age range +- **THEN** the clicked card SHALL show a blue active border +- **THEN** clicking the same card again SHALL remove the filter + +### Requirement: Hold Detail page SHALL display workcenter and package distribution +The page SHALL show distribution tables for workcenter and package breakdowns. + +#### Scenario: Distribution tables rendering +- **WHEN** distribution data is loaded +- **THEN** two side-by-side tables SHALL display: By Workcenter and By Package +- **THEN** each table SHALL show Name, Lots, QTY, and percentage columns +- **THEN** tables SHALL be scrollable with max-height 300px + +#### Scenario: Distribution row click filters lots +- **WHEN** user clicks a row in the workcenter or package table +- **THEN** the lot table SHALL reload filtered by that workcenter or package +- **THEN** the clicked row SHALL show an active highlight +- **THEN** clicking the same row again SHALL remove the filter + +### Requirement: Hold Detail page SHALL display paginated lot details +The page SHALL display detailed lot information with server-side pagination. + +#### Scenario: Lot table rendering +- **WHEN** lot data is loaded from `GET /api/wip/hold-detail/lots` +- **THEN** a table SHALL display with 10 columns: LOTID, WORKORDER, QTY, Package, Workcenter, Spec, Age, Hold By, Dept, Hold Comment +- **THEN** age values SHALL display with "天" suffix + +#### Scenario: Filter indicator +- **WHEN** any filter is active (workcenter, package, or age range) +- **THEN** a blue filter indicator bar SHALL display showing active filters (e.g., "篩選: Workcenter=WC-A, Age=3-7天") +- **THEN** clicking the "×" on the indicator SHALL clear all filters + +#### Scenario: Pagination +- **WHEN** total pages exceeds 1 +- **THEN** Prev/Next buttons and page info SHALL display +- **THEN** page info SHALL show "顯示 {start} - {end} / {total}" + +#### Scenario: Filter changes reset pagination +- **WHEN** any filter is toggled +- **THEN** pagination SHALL reset to page 1 + +### Requirement: Hold Detail page SHALL have back navigation to Overview +The page SHALL provide a way to return to the WIP Overview page. + +#### Scenario: Back button +- **WHEN** user clicks the "← WIP Overview" button in the header +- **THEN** the page SHALL navigate to `/wip-overview` + +### Requirement: Hold Detail 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 10 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: Request cancellation +- **WHEN** a new data load is triggered while a previous request is in-flight +- **THEN** the previous request SHALL be cancelled via AbortController +- **THEN** the cancelled request SHALL NOT update the UI + +#### Scenario: Manual refresh +- **WHEN** user clicks the "重新整理" button +- **THEN** data SHALL reload and the auto-refresh timer SHALL reset + +### Requirement: Hold Detail 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 full-page 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 + +#### Scenario: Empty lot result +- **WHEN** a query returns zero lots +- **THEN** the lot table SHALL display a "No data" placeholder diff --git a/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/vue-vite-page-architecture/spec.md b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/vue-vite-page-architecture/spec.md new file mode 100644 index 0000000..e5e777f --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/vue-vite-page-architecture/spec.md @@ -0,0 +1,47 @@ +## 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/wip-overview/main.js` → `src/wip-overview/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., `wip-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 + +## ADDED Requirements + +### Requirement: Pure Vite pages with server-side route validation SHALL use send_from_directory with pre-validation +Pages that require server-side parameter validation before serving SHALL validate parameters in the Flask route and then serve the static HTML. + +#### Scenario: Hold Detail reason validation +- **WHEN** user navigates to `/hold-detail` without a `reason` parameter +- **THEN** Flask SHALL redirect to `/wip-overview` +- **WHEN** user navigates to `/hold-detail?reason={value}` +- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/` via `send_from_directory` +- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering + +#### Scenario: Frontend fallback validation +- **WHEN** the pure Vite hold-detail page loads +- **THEN** the page SHALL read `reason` from URL parameters +- **THEN** if `reason` is empty or missing, the page SHALL redirect to `/wip-overview` diff --git a/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/wip-detail-page/spec.md b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/wip-detail-page/spec.md new file mode 100644 index 0000000..b9415fa --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/wip-detail-page/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: Detail page SHALL receive drill-down parameters from Overview +The page SHALL read URL query parameters to initialize its state from the Overview page drill-down. + +#### Scenario: URL parameter initialization +- **WHEN** the page loads with `?workcenter={name}` in the URL +- **THEN** the page SHALL use the specified workcenter for data loading +- **THEN** the page title SHALL display "WIP Detail - {workcenter}" + +#### Scenario: Filter passthrough from Overview +- **WHEN** the URL contains additional filter parameters (workorder, lotid, package, type) +- **THEN** filter inputs SHALL be pre-filled with those values +- **THEN** data SHALL be loaded with those filters applied + +#### Scenario: Missing workcenter fallback +- **WHEN** the page loads without a `workcenter` parameter +- **THEN** the page SHALL fetch available workcenters from `GET /api/wip/meta/workcenters` +- **THEN** the first workcenter SHALL be used and the URL SHALL be updated via `replaceState` + +### Requirement: Detail page SHALL display WIP summary cards +The page SHALL display five summary cards with status counts for the current workcenter. + +#### Scenario: Summary cards rendering +- **WHEN** detail data is loaded +- **THEN** five cards SHALL display: Total Lots, RUN, QUEUE, 品質異常, 非品質異常 + +#### Scenario: Status card click filters table +- **WHEN** user clicks a status card (RUN, QUEUE, 品質異常, 非品質異常) +- **THEN** the lot table SHALL reload filtered to that status +- **THEN** the active card SHALL show a visual active state +- **THEN** non-active status cards SHALL dim +- **THEN** clicking the same card again SHALL remove the filter + +### Requirement: Detail page SHALL display lot details table with sticky columns +The page SHALL display a scrollable table with fixed left columns and dynamic spec columns. + +#### Scenario: Table with sticky columns +- **WHEN** lot data is loaded from `GET /api/wip/detail/{workcenter}` +- **THEN** the table SHALL display with 4 sticky left columns: LOT ID, Equipment, WIP Status, Package +- **THEN** dynamic spec columns (e.g., 1OO, 2OO, TC) SHALL render to the right +- **THEN** the sticky columns SHALL remain visible during horizontal scroll + +#### Scenario: LOT ID is clickable +- **WHEN** user clicks a LOT ID in the table +- **THEN** the lot detail panel SHALL open below the table +- **THEN** the clicked LOT ID SHALL show an active highlight + +#### Scenario: WIP Status display +- **WHEN** a lot has status HOLD +- **THEN** the status cell SHALL display "HOLD ({holdReason})" with red styling +- **WHEN** a lot has status RUN or QUEUE +- **THEN** the status cell SHALL display with green or yellow styling respectively + +#### Scenario: Spec column data display +- **WHEN** a lot's spec matches a spec column +- **THEN** the cell SHALL display the lot QTY with green background +- **THEN** non-matching spec cells SHALL be empty + +### Requirement: Detail page SHALL display inline lot detail panel +The page SHALL show expandable lot detail information when a LOT ID is clicked. + +#### Scenario: Lot detail loading +- **WHEN** user clicks a LOT ID +- **THEN** the panel SHALL call `GET /api/wip/lot/{lotid}` +- **THEN** a loading indicator SHALL display while fetching + +#### Scenario: Lot detail sections +- **WHEN** lot detail data is loaded +- **THEN** the panel SHALL display sections: 基本資訊, 產品資訊, 製程資訊, 物料資訊 +- **THEN** Hold 資訊 section SHALL display only when status is HOLD or holdCount > 0 +- **THEN** NCR 資訊 section SHALL display only when ncrId exists + +#### Scenario: Close lot detail +- **WHEN** user clicks the Close button on the panel +- **THEN** the panel SHALL be hidden +- **THEN** the LOT ID highlight SHALL be removed + +### Requirement: Detail page SHALL support autocomplete filtering +The page SHALL provide autocomplete-enabled filter inputs identical to Overview. + +#### Scenario: Autocomplete with cross-filtering +- **WHEN** user types 2+ characters in a filter input +- **THEN** the page SHALL call `GET /api/wip/meta/search` with debounce (300ms) +- **THEN** cross-filter parameters SHALL be included +- **THEN** suggestions SHALL appear in a dropdown + +#### Scenario: Apply filters resets pagination +- **WHEN** user applies filters +- **THEN** pagination SHALL reset to page 1 +- **THEN** table data SHALL reload with the new filters + +### Requirement: Detail page SHALL support server-side pagination +The page SHALL paginate lot data with server-side support. + +#### Scenario: Pagination controls +- **WHEN** total pages exceeds 1 +- **THEN** Prev/Next buttons and page info SHALL display +- **THEN** Prev SHALL be disabled on page 1 +- **THEN** Next SHALL be disabled on the last page + +#### Scenario: Page navigation +- **WHEN** user clicks Next or Prev +- **THEN** data SHALL reload with the updated page number + +### Requirement: Detail page SHALL have back navigation to Overview +The page SHALL provide a way to return to the Overview page. + +#### Scenario: Back button +- **WHEN** user clicks the "← Overview" button in the header +- **THEN** the page SHALL navigate to `/wip-overview` + +### Requirement: Detail page SHALL auto-refresh and handle request cancellation +The page SHALL auto-refresh and cancel stale requests identically to Overview. + +#### Scenario: Auto-refresh and cancellation +- **WHEN** the page is loaded +- **THEN** data SHALL auto-refresh every 10 minutes, skipping when tab is hidden +- **THEN** visibility change SHALL trigger immediate refresh +- **THEN** new requests SHALL cancel in-flight requests via AbortController diff --git a/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/wip-overview-page/spec.md b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/wip-overview-page/spec.md new file mode 100644 index 0000000..a1a41a2 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/specs/wip-overview-page/spec.md @@ -0,0 +1,107 @@ +## ADDED Requirements + +### Requirement: Overview page SHALL display WIP summary statistics +The page SHALL fetch and display total lot count and total quantity as summary cards. + +#### Scenario: Summary cards rendering +- **WHEN** the page loads +- **THEN** the page SHALL call `GET /api/wip/overview/summary` +- **THEN** summary cards SHALL display Total Lots and Total QTY with zh-TW number formatting +- **THEN** values SHALL animate with a scale transition when updated + +#### Scenario: Data update timestamp +- **WHEN** summary data is loaded +- **THEN** the header SHALL display the `dataUpdateDate` from the API response + +### Requirement: Overview page SHALL display WIP status breakdown cards +The page SHALL display four clickable status cards (RUN, QUEUE, 品質異常, 非品質異常) with lot and quantity counts. + +#### Scenario: Status cards rendering +- **WHEN** summary data is loaded +- **THEN** four status cards SHALL be displayed with color coding (green=RUN, yellow=QUEUE, red=品質異常, orange=非品質異常) +- **THEN** each card SHALL show lot count and quantity + +#### Scenario: Status card click filters matrix +- **WHEN** user clicks a status card +- **THEN** the matrix table SHALL reload with the selected status filter +- **THEN** the clicked card SHALL show an active visual state +- **THEN** non-active cards SHALL dim to 50% opacity +- **THEN** clicking the same card again SHALL deactivate the filter and restore all cards + +### Requirement: Overview page SHALL display Workcenter × Package matrix +The page SHALL display a cross-tabulation table of workcenters vs packages. + +#### Scenario: Matrix table rendering +- **WHEN** matrix data is loaded from `GET /api/wip/overview/matrix` +- **THEN** the table SHALL display workcenters as rows and packages as columns (limited to top 15) +- **THEN** the first column (Workcenter) SHALL be sticky on horizontal scroll +- **THEN** a Total row and Total column SHALL be displayed + +#### Scenario: Matrix workcenter drill-down +- **WHEN** user clicks a workcenter name in the matrix +- **THEN** the page SHALL navigate to `/wip-detail?workcenter={name}` +- **THEN** active filter values (workorder, lotid, package, type) SHALL be passed as URL parameters + +### Requirement: Overview page SHALL display Hold Pareto analysis +The page SHALL display Pareto charts and tables for quality and non-quality hold reasons. + +#### Scenario: Pareto chart rendering +- **WHEN** hold data is loaded from `GET /api/wip/overview/hold` +- **THEN** hold items SHALL be split into quality and non-quality groups +- **THEN** each group SHALL display an ECharts dual-axis Pareto chart (bar=QTY, line=cumulative %) +- **THEN** items SHALL be sorted by QTY descending + +#### Scenario: Pareto chart drill-down +- **WHEN** user clicks a bar in the Pareto chart +- **THEN** the page SHALL navigate to `/hold-detail?reason={reason}` + +#### Scenario: Pareto table with drill-down links +- **WHEN** Pareto data is rendered +- **THEN** a table SHALL display below each chart with Hold Reason, Lots, QTY, and cumulative % +- **THEN** reason names SHALL be clickable links to `/hold-detail?reason={reason}` + +#### Scenario: Empty hold data +- **WHEN** a hold type has no items +- **THEN** the chart area SHALL display a "目前無資料" message +- **THEN** the chart SHALL be cleared + +### Requirement: Overview page SHALL support autocomplete filtering +The page SHALL provide autocomplete-enabled filter inputs for WORKORDER, LOT ID, PACKAGE, and TYPE. + +#### Scenario: Autocomplete search +- **WHEN** user types 2+ characters in a filter input +- **THEN** the page SHALL call `GET /api/wip/meta/search` with debounce (300ms) +- **THEN** suggestions SHALL appear in a dropdown below the input +- **THEN** cross-filter parameters SHALL be included (other active filter values) + +#### Scenario: Apply and clear filters +- **WHEN** user clicks "套用篩選" or presses Enter in a filter input +- **THEN** all three API calls (summary, matrix, hold) SHALL reload with the filter values +- **WHEN** user clicks "清除篩選" +- **THEN** all filter inputs SHALL be cleared and data SHALL reload without filters + +#### Scenario: Active filter display +- **WHEN** filters are applied +- **THEN** active filters SHALL be displayed as removable tags (e.g., "WO: {value} ×") +- **THEN** clicking a tag's remove button SHALL clear that filter and reload data + +### Requirement: Overview 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 10 minutes +- **THEN** auto-refresh SHALL be skipped when the tab is hidden (`document.hidden`) + +#### Scenario: Visibility change refresh +- **WHEN** the tab becomes visible after being hidden +- **THEN** data SHALL refresh immediately + +#### Scenario: Request cancellation +- **WHEN** a new data load is triggered while a previous request is in-flight +- **THEN** the previous request SHALL be cancelled via AbortController +- **THEN** the cancelled request SHALL NOT update the UI + +#### Scenario: Manual refresh +- **WHEN** user clicks the "重新整理" button +- **THEN** data SHALL reload and the auto-refresh timer SHALL reset diff --git a/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/tasks.md b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/tasks.md new file mode 100644 index 0000000..5794043 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-wip-trio-vue/tasks.md @@ -0,0 +1,61 @@ +## 1. Shared Infrastructure + +- [x] 1.1 Create `frontend/src/wip-shared/styles.css` with shared CSS variables (`:root`), gradient header, loading overlay, summary card, button, pagination, filter indicator, and responsive breakpoints extracted from the three pages +- [x] 1.2 Create `frontend/src/wip-shared/constants.js` with `NON_QUALITY_HOLD_REASONS` set (11 values matching backend `sql/filters.py`) +- [x] 1.3 Create `frontend/src/wip-shared/composables/useAutoRefresh.js` composable encapsulating 10-min interval, visibility-change refresh, and AbortController cancellation +- [x] 1.4 Create `frontend/src/wip-shared/composables/useAutocomplete.js` composable wrapping `core/autocomplete.js` with Vue reactive state, debounce 300ms, cross-filter, and dropdown management +- [x] 1.5 Create `frontend/src/wip-shared/components/Pagination.vue` shared pagination component (Prev/Next buttons, page info display) + +## 2. Hold Detail Page (336 lines → Vue 3) + +- [x] 2.1 Create `frontend/src/hold-detail/index.html` Vite entry point and `main.js` Vue app bootstrap +- [x] 2.2 Create `frontend/src/hold-detail/App.vue` with URL reason parameter reading, hold_type classification via `NON_QUALITY_HOLD_REASONS`, dynamic gradient header, missing-reason redirect, and auto-refresh setup +- [x] 2.3 Create `frontend/src/hold-detail/components/SummaryCards.vue` (5 cards: Total Lots, Total QTY, 平均當站滯留, 最久當站滯留, 影響站群) calling `GET /api/wip/hold-detail/summary` +- [x] 2.4 Create `frontend/src/hold-detail/components/AgeDistribution.vue` (4 clickable age cards: 0-1天, 1-3天, 3-7天, 7+天) with toggle filter +- [x] 2.5 Create `frontend/src/hold-detail/components/DistributionTable.vue` (reusable for workcenter and package tables) with row click filter toggle, calling `GET /api/wip/hold-detail/distribution` +- [x] 2.6 Create `frontend/src/hold-detail/components/LotTable.vue` (10 columns with filter indicator bar, "×" clear button) calling `GET /api/wip/hold-detail/lots` +- [x] 2.7 Create `frontend/src/hold-detail/style.css` with page-specific styles (dynamic gradient, age cards, distribution tables) importing `wip-shared/styles.css` +- [x] 2.8 Update `hold_routes.py` Flask route: keep reason validation redirect, change `render_template` to `send_from_directory` for `static/dist/hold-detail.html` +- [x] 2.9 Delete `src/mes_dashboard/templates/hold_detail.html` +- [x] 2.10 Verify Hold Detail page: summary cards, age/workcenter/package filtering, lot table pagination, back navigation, auto-refresh, missing-reason redirect + +## 3. WIP Overview Page (784 lines → Vue 3) + +- [x] 3.1 Create `frontend/src/wip-overview/index.html` Vite entry point and `main.js` Vue app bootstrap +- [x] 3.2 Create `frontend/src/wip-overview/App.vue` with data loading orchestration (summary + matrix + hold), auto-refresh setup, and filter state management +- [x] 3.3 Create `frontend/src/wip-overview/components/FilterPanel.vue` (4 autocomplete inputs using `useAutocomplete` composable, active filter tags with remove buttons, apply/clear buttons) +- [x] 3.4 Create `frontend/src/wip-overview/components/SummaryCards.vue` (2 KPI cards: Total Lots, Total QTY with zh-TW number formatting and scale transition) +- [x] 3.5 Create `frontend/src/wip-overview/components/StatusCards.vue` (4 clickable status cards: RUN, QUEUE, 品質異常, 非品質異常 with dim/active toggle) +- [x] 3.6 Create `frontend/src/wip-overview/components/MatrixTable.vue` (workcenter × package cross-tab, sticky first column, Total row/column, workcenter click drill-down to `/wip-detail`) +- [x] 3.7 Create `frontend/src/wip-overview/components/ParetoSection.vue` (ECharts dual-axis Pareto chart + detail table, bar click drill-down to `/hold-detail`, "目前無資料" empty state) — used as 2 instances (quality/non-quality) +- [x] 3.8 Create `frontend/src/wip-overview/style.css` with page-specific styles importing `wip-shared/styles.css` +- [x] 3.9 Update `app.py` Flask route for `/wip-overview`: change `render_template` to `send_from_directory` +- [x] 3.10 Delete `src/mes_dashboard/templates/wip_overview.html` +- [x] 3.11 Verify Overview page: summary cards, status card filtering, matrix drill-down, Pareto chart rendering and drill-down, autocomplete filtering, auto-refresh + +## 4. WIP Detail Page (821 lines → Vue 3) + +- [x] 4.1 Create `frontend/src/wip-detail/index.html` Vite entry point and `main.js` Vue app bootstrap +- [x] 4.2 Create `frontend/src/wip-detail/App.vue` with URL parameter initialization (workcenter + 4 filters), workcenter fallback fetch, auto-refresh setup, and replaceState URL update +- [x] 4.3 Create `frontend/src/wip-detail/components/FilterPanel.vue` (4 autocomplete inputs using `useAutocomplete` composable, shared with Overview pattern) +- [x] 4.4 Create `frontend/src/wip-detail/components/SummaryCards.vue` (5 clickable status cards: Total Lots, RUN, QUEUE, 品質異常, 非品質異常 with dim/active toggle) +- [x] 4.5 Create `frontend/src/wip-detail/components/LotTable.vue` (4 sticky left columns with cascading left positions, dynamic spec columns, LOT ID click to open detail panel, status color coding) +- [x] 4.6 Create `frontend/src/wip-detail/components/LotDetailPanel.vue` (inline expandable panel with 4-column grid: 基本資訊, 產品資訊, 製程資訊, 物料資訊, conditional Hold/NCR sections) +- [x] 4.7 Create `frontend/src/wip-detail/style.css` with page-specific styles (sticky columns, spec column coloring, lot detail panel) importing `wip-shared/styles.css` +- [x] 4.8 Update `app.py` Flask route for `/wip-detail`: change `render_template` to `send_from_directory` +- [x] 4.9 Delete `src/mes_dashboard/templates/wip_detail.html` +- [x] 4.10 Verify Detail page: URL param initialization, workcenter fallback, summary card filtering, sticky table scrolling, lot detail panel, autocomplete filtering, pagination, auto-refresh + +## 5. Vite Configuration & Build + +- [x] 5.1 Update `vite.config.js`: change 3 entries from `main.js` to `index.html`, add `vendor-vue` chunk splitting rule, ensure `vendor-echarts` chunk includes Overview usage +- [x] 5.2 Update `package.json` build script to copy the 3 new HTML files to `static/dist/` +- [x] 5.3 Run `npm run build` and verify output includes `wip-overview.html/js/css`, `wip-detail.html/js/css`, `hold-detail.html/js/css` with correct chunk splitting + +## 6. Integration & Cleanup + +- [x] 6.1 Verify drill-down navigation: Overview → Detail (workcenter + filters URL params), Overview → Hold Detail (Pareto chart/table reason links) +- [x] 6.2 Verify back navigation: Detail → Overview, Hold Detail → Overview +- [x] 6.3 Verify portal iframe embedding works for all three pages +- [x] 6.4 Remove unused imports of `escapeHtml`/`safeText` from `core/table-tree.js` in hold-detail (Vue handles escaping) +- [x] 6.5 Verify all three pages render correctly at responsive breakpoints (1400px, 1000px, 768px) diff --git a/openspec/specs/hold-detail-page/spec.md b/openspec/specs/hold-detail-page/spec.md new file mode 100644 index 0000000..44bd62e --- /dev/null +++ b/openspec/specs/hold-detail-page/spec.md @@ -0,0 +1,119 @@ +## Purpose +Define stable requirements for hold-detail-page. + +## Requirements + + +### Requirement: Hold Detail page SHALL display hold reason analysis +The page SHALL show summary statistics for a specific hold reason. + +#### Scenario: Summary cards rendering +- **WHEN** the page loads with `?reason={reason}` in the URL +- **THEN** the page SHALL call `GET /api/wip/hold-detail/summary` with the reason +- **THEN** five cards SHALL display: Total Lots, Total QTY, 平均當站滯留, 最久當站滯留, 影響站群 +- **THEN** age values SHALL display with "天" suffix + +#### Scenario: Hold type classification +- **WHEN** the page loads +- **THEN** the header gradient color SHALL be red (#ef4444) for quality holds or orange (#f97316) for non-quality holds +- **THEN** a badge SHALL display "品質異常" or "非品質異常" accordingly +- **THEN** classification SHALL use a frontend `NON_QUALITY_HOLD_REASONS` constant set (11 values matching backend `sql/filters.py`) + +#### Scenario: Missing reason parameter +- **WHEN** the page loads without a `reason` URL parameter +- **THEN** the page SHALL redirect to `/wip-overview` + +### Requirement: Hold Detail page SHALL display age distribution +The page SHALL show the distribution of hold lots by age at current station. + +#### Scenario: Age distribution cards +- **WHEN** distribution data is loaded from `GET /api/wip/hold-detail/distribution` +- **THEN** four clickable cards SHALL display: 0-1天, 1-3天, 3-7天, 7+天 +- **THEN** each card SHALL show Lots, QTY, and percentage + +#### Scenario: Age card click filters lots +- **WHEN** user clicks an age card +- **THEN** the lot table SHALL reload filtered to that age range +- **THEN** the clicked card SHALL show a blue active border +- **THEN** clicking the same card again SHALL remove the filter + +### Requirement: Hold Detail page SHALL display workcenter and package distribution +The page SHALL show distribution tables for workcenter and package breakdowns. + +#### Scenario: Distribution tables rendering +- **WHEN** distribution data is loaded +- **THEN** two side-by-side tables SHALL display: By Workcenter and By Package +- **THEN** each table SHALL show Name, Lots, QTY, and percentage columns +- **THEN** tables SHALL be scrollable with max-height 300px + +#### Scenario: Distribution row click filters lots +- **WHEN** user clicks a row in the workcenter or package table +- **THEN** the lot table SHALL reload filtered by that workcenter or package +- **THEN** the clicked row SHALL show an active highlight +- **THEN** clicking the same row again SHALL remove the filter + +### Requirement: Hold Detail page SHALL display paginated lot details +The page SHALL display detailed lot information with server-side pagination. + +#### Scenario: Lot table rendering +- **WHEN** lot data is loaded from `GET /api/wip/hold-detail/lots` +- **THEN** a table SHALL display with 10 columns: LOTID, WORKORDER, QTY, Package, Workcenter, Spec, Age, Hold By, Dept, Hold Comment +- **THEN** age values SHALL display with "天" suffix + +#### Scenario: Filter indicator +- **WHEN** any filter is active (workcenter, package, or age range) +- **THEN** a blue filter indicator bar SHALL display showing active filters (e.g., "篩選: Workcenter=WC-A, Age=3-7天") +- **THEN** clicking the "×" on the indicator SHALL clear all filters + +#### Scenario: Pagination +- **WHEN** total pages exceeds 1 +- **THEN** Prev/Next buttons and page info SHALL display +- **THEN** page info SHALL show "顯示 {start} - {end} / {total}" + +#### Scenario: Filter changes reset pagination +- **WHEN** any filter is toggled +- **THEN** pagination SHALL reset to page 1 + +### Requirement: Hold Detail page SHALL have back navigation to Overview +The page SHALL provide a way to return to the WIP Overview page. + +#### Scenario: Back button +- **WHEN** user clicks the "← WIP Overview" button in the header +- **THEN** the page SHALL navigate to `/wip-overview` + +### Requirement: Hold Detail 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 10 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: Request cancellation +- **WHEN** a new data load is triggered while a previous request is in-flight +- **THEN** the previous request SHALL be cancelled via AbortController +- **THEN** the cancelled request SHALL NOT update the UI + +#### Scenario: Manual refresh +- **WHEN** user clicks the "重新整理" button +- **THEN** data SHALL reload and the auto-refresh timer SHALL reset + +### Requirement: Hold Detail 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 full-page 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 + +#### Scenario: Empty lot result +- **WHEN** a query returns zero lots +- **THEN** the lot table SHALL display a "No data" placeholder diff --git a/openspec/specs/vue-vite-page-architecture/spec.md b/openspec/specs/vue-vite-page-architecture/spec.md index 7d71362..ff21305 100644 --- a/openspec/specs/vue-vite-page-architecture/spec.md +++ b/openspec/specs/vue-vite-page-architecture/spec.md @@ -38,9 +38,14 @@ The Vite build configuration SHALL support Vue Single File Components alongside #### 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/tables/main.js` → `src/tables/index.html`) +- **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/wip-overview/main.js` → `src/wip-overview/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., `wip-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 + ### 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`. @@ -62,3 +67,18 @@ Pure Vite pages SHALL use the `apiPost` function from `core/api.js` for POST req - **WHEN** a pure Vite page calls `apiPost` - **THEN** `apiPost` SHALL attempt to read CSRF token from `` - **THEN** if no meta tag exists, the request SHALL still proceed (non-admin APIs do not enforce CSRF) + +### Requirement: Pure Vite pages with server-side route validation SHALL use send_from_directory with pre-validation +Pages that require server-side parameter validation before serving SHALL validate parameters in the Flask route and then serve the static HTML. + +#### Scenario: Hold Detail reason validation +- **WHEN** user navigates to `/hold-detail` without a `reason` parameter +- **THEN** Flask SHALL redirect to `/wip-overview` +- **WHEN** user navigates to `/hold-detail?reason={value}` +- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/` via `send_from_directory` +- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering + +#### Scenario: Frontend fallback validation +- **WHEN** the pure Vite hold-detail page loads +- **THEN** the page SHALL read `reason` from URL parameters +- **THEN** if `reason` is empty or missing, the page SHALL redirect to `/wip-overview` diff --git a/openspec/specs/wip-detail-page/spec.md b/openspec/specs/wip-detail-page/spec.md new file mode 100644 index 0000000..00f201b --- /dev/null +++ b/openspec/specs/wip-detail-page/spec.md @@ -0,0 +1,124 @@ +## Purpose +Define stable requirements for wip-detail-page. + +## Requirements + + +### Requirement: Detail page SHALL receive drill-down parameters from Overview +The page SHALL read URL query parameters to initialize its state from the Overview page drill-down. + +#### Scenario: URL parameter initialization +- **WHEN** the page loads with `?workcenter={name}` in the URL +- **THEN** the page SHALL use the specified workcenter for data loading +- **THEN** the page title SHALL display "WIP Detail - {workcenter}" + +#### Scenario: Filter passthrough from Overview +- **WHEN** the URL contains additional filter parameters (workorder, lotid, package, type) +- **THEN** filter inputs SHALL be pre-filled with those values +- **THEN** data SHALL be loaded with those filters applied + +#### Scenario: Missing workcenter fallback +- **WHEN** the page loads without a `workcenter` parameter +- **THEN** the page SHALL fetch available workcenters from `GET /api/wip/meta/workcenters` +- **THEN** the first workcenter SHALL be used and the URL SHALL be updated via `replaceState` + +### Requirement: Detail page SHALL display WIP summary cards +The page SHALL display five summary cards with status counts for the current workcenter. + +#### Scenario: Summary cards rendering +- **WHEN** detail data is loaded +- **THEN** five cards SHALL display: Total Lots, RUN, QUEUE, 品質異常, 非品質異常 + +#### Scenario: Status card click filters table +- **WHEN** user clicks a status card (RUN, QUEUE, 品質異常, 非品質異常) +- **THEN** the lot table SHALL reload filtered to that status +- **THEN** the active card SHALL show a visual active state +- **THEN** non-active status cards SHALL dim +- **THEN** clicking the same card again SHALL remove the filter + +### Requirement: Detail page SHALL display lot details table with sticky columns +The page SHALL display a scrollable table with fixed left columns and dynamic spec columns. + +#### Scenario: Table with sticky columns +- **WHEN** lot data is loaded from `GET /api/wip/detail/{workcenter}` +- **THEN** the table SHALL display with 4 sticky left columns: LOT ID, Equipment, WIP Status, Package +- **THEN** dynamic spec columns (e.g., 1OO, 2OO, TC) SHALL render to the right +- **THEN** the sticky columns SHALL remain visible during horizontal scroll + +#### Scenario: LOT ID is clickable +- **WHEN** user clicks a LOT ID in the table +- **THEN** the lot detail panel SHALL open below the table +- **THEN** the clicked LOT ID SHALL show an active highlight + +#### Scenario: WIP Status display +- **WHEN** a lot has status HOLD +- **THEN** the status cell SHALL display "HOLD ({holdReason})" with red styling +- **WHEN** a lot has status RUN or QUEUE +- **THEN** the status cell SHALL display with green or yellow styling respectively + +#### Scenario: Spec column data display +- **WHEN** a lot's spec matches a spec column +- **THEN** the cell SHALL display the lot QTY with green background +- **THEN** non-matching spec cells SHALL be empty + +### Requirement: Detail page SHALL display inline lot detail panel +The page SHALL show expandable lot detail information when a LOT ID is clicked. + +#### Scenario: Lot detail loading +- **WHEN** user clicks a LOT ID +- **THEN** the panel SHALL call `GET /api/wip/lot/{lotid}` +- **THEN** a loading indicator SHALL display while fetching + +#### Scenario: Lot detail sections +- **WHEN** lot detail data is loaded +- **THEN** the panel SHALL display sections: 基本資訊, 產品資訊, 製程資訊, 物料資訊 +- **THEN** Hold 資訊 section SHALL display only when status is HOLD or holdCount > 0 +- **THEN** NCR 資訊 section SHALL display only when ncrId exists + +#### Scenario: Close lot detail +- **WHEN** user clicks the Close button on the panel +- **THEN** the panel SHALL be hidden +- **THEN** the LOT ID highlight SHALL be removed + +### Requirement: Detail page SHALL support autocomplete filtering +The page SHALL provide autocomplete-enabled filter inputs identical to Overview. + +#### Scenario: Autocomplete with cross-filtering +- **WHEN** user types 2+ characters in a filter input +- **THEN** the page SHALL call `GET /api/wip/meta/search` with debounce (300ms) +- **THEN** cross-filter parameters SHALL be included +- **THEN** suggestions SHALL appear in a dropdown + +#### Scenario: Apply filters resets pagination +- **WHEN** user applies filters +- **THEN** pagination SHALL reset to page 1 +- **THEN** table data SHALL reload with the new filters + +### Requirement: Detail page SHALL support server-side pagination +The page SHALL paginate lot data with server-side support. + +#### Scenario: Pagination controls +- **WHEN** total pages exceeds 1 +- **THEN** Prev/Next buttons and page info SHALL display +- **THEN** Prev SHALL be disabled on page 1 +- **THEN** Next SHALL be disabled on the last page + +#### Scenario: Page navigation +- **WHEN** user clicks Next or Prev +- **THEN** data SHALL reload with the updated page number + +### Requirement: Detail page SHALL have back navigation to Overview +The page SHALL provide a way to return to the Overview page. + +#### Scenario: Back button +- **WHEN** user clicks the "← Overview" button in the header +- **THEN** the page SHALL navigate to `/wip-overview` + +### Requirement: Detail page SHALL auto-refresh and handle request cancellation +The page SHALL auto-refresh and cancel stale requests identically to Overview. + +#### Scenario: Auto-refresh and cancellation +- **WHEN** the page is loaded +- **THEN** data SHALL auto-refresh every 10 minutes, skipping when tab is hidden +- **THEN** visibility change SHALL trigger immediate refresh +- **THEN** new requests SHALL cancel in-flight requests via AbortController diff --git a/openspec/specs/wip-overview-page/spec.md b/openspec/specs/wip-overview-page/spec.md new file mode 100644 index 0000000..82a0a20 --- /dev/null +++ b/openspec/specs/wip-overview-page/spec.md @@ -0,0 +1,111 @@ +## Purpose +Define stable requirements for wip-overview-page. + +## Requirements + + +### Requirement: Overview page SHALL display WIP summary statistics +The page SHALL fetch and display total lot count and total quantity as summary cards. + +#### Scenario: Summary cards rendering +- **WHEN** the page loads +- **THEN** the page SHALL call `GET /api/wip/overview/summary` +- **THEN** summary cards SHALL display Total Lots and Total QTY with zh-TW number formatting +- **THEN** values SHALL animate with a scale transition when updated + +#### Scenario: Data update timestamp +- **WHEN** summary data is loaded +- **THEN** the header SHALL display the `dataUpdateDate` from the API response + +### Requirement: Overview page SHALL display WIP status breakdown cards +The page SHALL display four clickable status cards (RUN, QUEUE, 品質異常, 非品質異常) with lot and quantity counts. + +#### Scenario: Status cards rendering +- **WHEN** summary data is loaded +- **THEN** four status cards SHALL be displayed with color coding (green=RUN, yellow=QUEUE, red=品質異常, orange=非品質異常) +- **THEN** each card SHALL show lot count and quantity + +#### Scenario: Status card click filters matrix +- **WHEN** user clicks a status card +- **THEN** the matrix table SHALL reload with the selected status filter +- **THEN** the clicked card SHALL show an active visual state +- **THEN** non-active cards SHALL dim to 50% opacity +- **THEN** clicking the same card again SHALL deactivate the filter and restore all cards + +### Requirement: Overview page SHALL display Workcenter × Package matrix +The page SHALL display a cross-tabulation table of workcenters vs packages. + +#### Scenario: Matrix table rendering +- **WHEN** matrix data is loaded from `GET /api/wip/overview/matrix` +- **THEN** the table SHALL display workcenters as rows and packages as columns (limited to top 15) +- **THEN** the first column (Workcenter) SHALL be sticky on horizontal scroll +- **THEN** a Total row and Total column SHALL be displayed + +#### Scenario: Matrix workcenter drill-down +- **WHEN** user clicks a workcenter name in the matrix +- **THEN** the page SHALL navigate to `/wip-detail?workcenter={name}` +- **THEN** active filter values (workorder, lotid, package, type) SHALL be passed as URL parameters + +### Requirement: Overview page SHALL display Hold Pareto analysis +The page SHALL display Pareto charts and tables for quality and non-quality hold reasons. + +#### Scenario: Pareto chart rendering +- **WHEN** hold data is loaded from `GET /api/wip/overview/hold` +- **THEN** hold items SHALL be split into quality and non-quality groups +- **THEN** each group SHALL display an ECharts dual-axis Pareto chart (bar=QTY, line=cumulative %) +- **THEN** items SHALL be sorted by QTY descending + +#### Scenario: Pareto chart drill-down +- **WHEN** user clicks a bar in the Pareto chart +- **THEN** the page SHALL navigate to `/hold-detail?reason={reason}` + +#### Scenario: Pareto table with drill-down links +- **WHEN** Pareto data is rendered +- **THEN** a table SHALL display below each chart with Hold Reason, Lots, QTY, and cumulative % +- **THEN** reason names SHALL be clickable links to `/hold-detail?reason={reason}` + +#### Scenario: Empty hold data +- **WHEN** a hold type has no items +- **THEN** the chart area SHALL display a "目前無資料" message +- **THEN** the chart SHALL be cleared + +### Requirement: Overview page SHALL support autocomplete filtering +The page SHALL provide autocomplete-enabled filter inputs for WORKORDER, LOT ID, PACKAGE, and TYPE. + +#### Scenario: Autocomplete search +- **WHEN** user types 2+ characters in a filter input +- **THEN** the page SHALL call `GET /api/wip/meta/search` with debounce (300ms) +- **THEN** suggestions SHALL appear in a dropdown below the input +- **THEN** cross-filter parameters SHALL be included (other active filter values) + +#### Scenario: Apply and clear filters +- **WHEN** user clicks "套用篩選" or presses Enter in a filter input +- **THEN** all three API calls (summary, matrix, hold) SHALL reload with the filter values +- **WHEN** user clicks "清除篩選" +- **THEN** all filter inputs SHALL be cleared and data SHALL reload without filters + +#### Scenario: Active filter display +- **WHEN** filters are applied +- **THEN** active filters SHALL be displayed as removable tags (e.g., "WO: {value} ×") +- **THEN** clicking a tag's remove button SHALL clear that filter and reload data + +### Requirement: Overview 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 10 minutes +- **THEN** auto-refresh SHALL be skipped when the tab is hidden (`document.hidden`) + +#### Scenario: Visibility change refresh +- **WHEN** the tab becomes visible after being hidden +- **THEN** data SHALL refresh immediately + +#### Scenario: Request cancellation +- **WHEN** a new data load is triggered while a previous request is in-flight +- **THEN** the previous request SHALL be cancelled via AbortController +- **THEN** the cancelled request SHALL NOT update the UI + +#### Scenario: Manual refresh +- **WHEN** user clicks the "重新整理" button +- **THEN** data SHALL reload and the auto-refresh timer SHALL reset diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index 9de3981..2578f9b 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -402,13 +402,39 @@ def create_app(config_name: str | None = None) -> Flask: @app.route('/wip-overview') def wip_overview_page(): - """WIP Overview Dashboard - for executives.""" - return render_template('wip_overview.html') + """WIP Overview Dashboard served as pure Vite HTML output.""" + dist_dir = os.path.join(app.static_folder or "", "dist") + dist_html = os.path.join(dist_dir, "wip-overview.html") + if os.path.exists(dist_html): + return send_from_directory(dist_dir, 'wip-overview.html') + + # Test/local fallback when frontend build artifacts are absent. + return ( + "" + "" + "WIP Overview Dashboard" + "" + "
", + 200, + ) @app.route('/wip-detail') def wip_detail_page(): - """WIP Detail Dashboard - for production lines.""" - return render_template('wip_detail.html') + """WIP Detail Dashboard served as pure Vite HTML output.""" + dist_dir = os.path.join(app.static_folder or "", "dist") + dist_html = os.path.join(dist_dir, "wip-detail.html") + if os.path.exists(dist_html): + return send_from_directory(dist_dir, 'wip-detail.html') + + # Test/local fallback when frontend build artifacts are absent. + return ( + "" + "" + "WIP Detail Dashboard" + "" + "
", + 200, + ) @app.route('/resource') def resource_page(): diff --git a/src/mes_dashboard/routes/hold_routes.py b/src/mes_dashboard/routes/hold_routes.py index 3ff244c..c93a811 100644 --- a/src/mes_dashboard/routes/hold_routes.py +++ b/src/mes_dashboard/routes/hold_routes.py @@ -1,19 +1,21 @@ # -*- coding: utf-8 -*- -"""Hold Detail API routes for MES Dashboard. - -Contains Flask Blueprint for Hold Detail page and API endpoints. -""" - -from flask import Blueprint, jsonify, request, render_template, redirect, url_for +"""Hold Detail API routes for MES Dashboard. + +Contains Flask Blueprint for Hold Detail page and API endpoints. +""" + +import html +import os + +from flask import Blueprint, current_app, jsonify, redirect, request, send_from_directory from mes_dashboard.core.rate_limit import configured_rate_limit from mes_dashboard.core.utils import parse_bool_query from mes_dashboard.services.wip_service import ( - get_hold_detail_summary, - get_hold_detail_distribution, - get_hold_detail_lots, - is_quality_hold, -) + get_hold_detail_summary, + get_hold_detail_distribution, + get_hold_detail_lots, +) # Create Blueprint hold_bp = Blueprint('hold', __name__) @@ -31,8 +33,8 @@ _HOLD_LOTS_RATE_LIMIT = configured_rate_limit( # Page Route # ============================================================ -@hold_bp.route('/hold-detail') -def hold_detail_page(): +@hold_bp.route('/hold-detail') +def hold_detail_page(): """Render the Hold Detail page. Query Parameters: @@ -46,8 +48,22 @@ def hold_detail_page(): # Redirect to WIP Overview when reason is missing return redirect('/wip-overview') - hold_type = 'quality' if is_quality_hold(reason) else 'non-quality' - return render_template('hold_detail.html', reason=reason, hold_type=hold_type) + # Keep server-side validation, then serve static Vite output directly. + dist_dir = os.path.join(current_app.static_folder or "", "dist") + dist_html = os.path.join(dist_dir, "hold-detail.html") + if os.path.exists(dist_html): + return send_from_directory(dist_dir, 'hold-detail.html') + + safe_reason = html.escape(reason, quote=True) + return ( + "" + "" + f"Hold Detail - {safe_reason}" + "" + f"" + "
", + 200, + ) # ============================================================ diff --git a/src/mes_dashboard/templates/hold_detail.html b/src/mes_dashboard/templates/hold_detail.html deleted file mode 100644 index 3ff8f4d..0000000 --- a/src/mes_dashboard/templates/hold_detail.html +++ /dev/null @@ -1,1013 +0,0 @@ -{% extends "_base.html" %} - -{% block title %}Hold Detail - {{ reason }}{% endblock %} - -{% block head_extra %} - -{% endblock %} - -{% block content %} -
- -
-
- ← WIP Overview -

Hold Detail: {{ reason }}

- {% if hold_type == 'quality' %}品質異常{% else %}非品質異常{% endif %} -
-
- - - - - -
-
- - -
-
-
Total Lots
-
-
-
-
-
Total QTY
-
-
-
-
-
平均當站滯留
-
-
-
-
-
最久當站滯留
-
-
-
-
-
影響站群
-
-
-
-
- - -
當站滯留天數分佈 (Age at Current Station)
-
-
-
0-1天
-
-
Lots-
-
QTY-
-
-
-
-
-
-
1-3天
-
-
Lots-
-
QTY-
-
-
-
-
-
-
3-7天
-
-
Lots-
-
QTY-
-
-
-
-
-
-
7+天
-
-
Lots-
-
QTY-
-
-
-
-
-
- - -
-
-
-
By Workcenter
-
-
- - - - - - - - - - - - -
WorkcenterLotsQTY%
Loading...
-
-
-
-
-
By Package
-
-
- - - - - - - - - - - - -
PackageLotsQTY%
Loading...
-
-
-
- - -
-
-
Lot Details
- -
Loading...
-
-
- - - - - - - - - - - - - - - - - - -
LOTIDWORKORDERQTYPackageWorkcenterSpecAgeHold ByDeptHold Comment
Loading...
-
- -
-
- - -
- - Loading... -
-{% endblock %} - -{% block scripts %} -{% set hold_detail_js = frontend_asset('hold-detail.js') %} -{% if hold_detail_js %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/src/mes_dashboard/templates/wip_detail.html b/src/mes_dashboard/templates/wip_detail.html deleted file mode 100644 index e84cc11..0000000 --- a/src/mes_dashboard/templates/wip_detail.html +++ /dev/null @@ -1,1794 +0,0 @@ -{% extends "_base.html" %} - -{% block title %}WIP Detail Dashboard{% endblock %} - -{% block head_extra %} - -{% endblock %} - -{% block content %} -
- -
-
- ← Overview -

WIP Detail

-
-
- - - - - - - -
-
- - -
-
- -
- -
-
-
-
- -
- -
-
-
-
- -
- -
-
-
-
- -
- -
-
-
- - -
- - -
-
-
Total Lots
-
-
-
-
-
RUN
-
-
-
-
-
QUEUE
-
-
-
-
-
品質異常
-
-
-
-
-
非品質異常
-
-
-
-
- - -
-
-
Lot Details
-
Loading...
-
-
-
Loading...
-
- -
- - -
-
-
- Lot Detail - -
- -
-
-
- Loading... -
-
-
-
- - -
- - Loading... -
-{% endblock %} - -{% block scripts %} -{% set wip_detail_js = frontend_asset('wip-detail.js') %} -{% if wip_detail_js %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/src/mes_dashboard/templates/wip_overview.html b/src/mes_dashboard/templates/wip_overview.html deleted file mode 100644 index 8dc05d4..0000000 --- a/src/mes_dashboard/templates/wip_overview.html +++ /dev/null @@ -1,1825 +0,0 @@ -{% extends "_base.html" %} - -{% block title %}WIP Overview Dashboard{% endblock %} - -{% block head_extra %} - - -{% endblock %} - -{% block content %} -
- -
-

WIP Overview Dashboard

-
- - - - - - - -
-
- - -
-
- - - -
-
-
- - - -
-
-
- - - -
-
-
- - - -
-
- - - -
- - -
-
-
Total Lots
-
-
-
-
-
Total QTY
-
-
-
-
- - -
-
-
RUN
-
- - - - -
-
-
-
QUEUE
-
- - - - -
-
-
-
品質異常
-
- - - - -
-
-
-
非品質異常
-
- - - - -
-
-
- - -
- -
-
-
Workcenter x Package Matrix (QTY)
-
-
-
-
Loading...
-
-
-
- - -
- -
-
-
- 品質異常 Hold - 0 項 -
-
-
-
- -
-
-
- -
-
-
- 非品質異常 Hold - 0 項 -
-
-
-
- -
-
-
-
-
-
- - -
- - Loading... -
-{% endblock %} - -{% block scripts %} -{% set wip_overview_js = frontend_asset('wip-overview.js') %} -{% if wip_overview_js %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/tests/test_hold_routes.py b/tests/test_hold_routes.py index 1e6a3b7..cd2de3e 100644 --- a/tests/test_hold_routes.py +++ b/tests/test_hold_routes.py @@ -37,10 +37,11 @@ class TestHoldDetailPageRoute(TestHoldRoutesBase): response = self.client.get('/hold-detail?reason=YieldLimit') self.assertEqual(response.status_code, 200) - def test_hold_detail_page_contains_reason_in_html(self): - """Page should display the hold reason in the HTML.""" - response = self.client.get('/hold-detail?reason=YieldLimit') - self.assertIn(b'YieldLimit', response.data) + def test_hold_detail_page_includes_vite_entry(self): + """Page should load the Hold Detail Vite module.""" + response = self.client.get('/hold-detail?reason=YieldLimit') + self.assertEqual(response.status_code, 200) + self.assertIn(b'/static/dist/hold-detail.js', response.data) class TestHoldDetailSummaryRoute(TestHoldRoutesBase): diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index cc6e9df..9828854 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -36,23 +36,23 @@ class TestTemplateIntegration(unittest.TestCase): self.assertIn('mes-api.js', html) self.assertIn('mes-toast-container', html) - def test_wip_overview_includes_base_scripts(self): + def test_wip_overview_serves_pure_vite_module(self): response = self.client.get('/wip-overview') 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/wip-overview.js', html) + self.assertIn('type="module"', html) + self.assertNotIn('mes-toast-container', html) - def test_wip_detail_includes_base_scripts(self): + def test_wip_detail_serves_pure_vite_module(self): response = self.client.get('/wip-detail') 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/wip-detail.js', html) + self.assertIn('type="module"', html) + self.assertNotIn('mes-toast-container', html) def test_tables_page_serves_pure_vite_module(self): response = self.client.get('/tables') @@ -219,19 +219,19 @@ class TestToastCSSIntegration(unittest.TestCase): self.assertIn('.mes-toast-container', html) self.assertIn('.mes-toast', html) - def test_wip_overview_includes_toast_css(self): + def test_wip_overview_excludes_toast_css(self): response = self.client.get('/wip-overview') html = response.data.decode('utf-8') - self.assertIn('.mes-toast-container', html) - self.assertIn('.mes-toast', html) + self.assertNotIn('.mes-toast-container', html) + self.assertNotIn('.mes-toast', html) - def test_wip_detail_includes_toast_css(self): + def test_wip_detail_excludes_toast_css(self): response = self.client.get('/wip-detail') html = response.data.decode('utf-8') - self.assertIn('.mes-toast-container', html) - self.assertIn('.mes-toast', html) + self.assertNotIn('.mes-toast-container', html) + self.assertNotIn('.mes-toast', html) class TestMesApiUsageInTemplates(unittest.TestCase):