feat(wip): migrate WIP trio pages from Jinja2 to Vue 3 + Vite
Migrate /wip-overview, /wip-detail, and /hold-detail (1,941 lines vanilla JS) to Vue 3 SFC architecture. Extract shared CSS/constants/components to wip-shared/. Switch Pareto charts to vue-echarts with autoresize. Replace Jinja2 template injection with frontend URL params + constant classification for Hold Detail. Add 10-min auto-refresh + AbortController to Hold Detail. Remove three Jinja2 templates, update Flask routes to send_from_directory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
58
README.md
58
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(`<VChart autoresize>`),與 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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -33,59 +33,160 @@ function buildApiError(response, payload) {
|
||||
return error;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
function buildUrlWithParams(url, params) {
|
||||
if (!params || typeof params !== 'object') {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
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 data = await response.json();
|
||||
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();
|
||||
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(requestUrl, {
|
||||
...fetchOptions,
|
||||
signal,
|
||||
});
|
||||
|
||||
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({
|
||||
headers: withCsrfHeaders(
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
}, 'POST'),
|
||||
body: JSON.stringify(payload)
|
||||
...(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;
|
||||
}
|
||||
|
||||
316
frontend/src/hold-detail/App.vue
Normal file
316
frontend/src/hold-detail/App.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import { NON_QUALITY_HOLD_REASON_SET } from '../wip-shared/constants.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
|
||||
import AgeDistribution from './components/AgeDistribution.vue';
|
||||
import DistributionTable from './components/DistributionTable.vue';
|
||||
import LotTable from './components/LotTable.vue';
|
||||
import SummaryCards from './components/SummaryCards.vue';
|
||||
|
||||
const REASON = new URLSearchParams(window.location.search).get('reason')?.trim() || '';
|
||||
const API_TIMEOUT = 60000;
|
||||
|
||||
const summary = ref(null);
|
||||
const distribution = ref(null);
|
||||
const lots = ref([]);
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const filters = reactive({
|
||||
workcenter: null,
|
||||
package: null,
|
||||
ageRange: null,
|
||||
});
|
||||
|
||||
const page = ref(1);
|
||||
const initialLoading = ref(true);
|
||||
const refreshing = ref(false);
|
||||
const lotsLoading = ref(false);
|
||||
const lotsError = ref('');
|
||||
const loadError = ref('');
|
||||
const lastUpdate = ref('');
|
||||
|
||||
function unwrapApiResult(result, fallbackMessage) {
|
||||
if (result?.success) {
|
||||
return result.data;
|
||||
}
|
||||
if (result?.success === false) {
|
||||
throw new Error(result.error || fallbackMessage);
|
||||
}
|
||||
if (result?.data !== undefined) {
|
||||
return result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchSummary(signal) {
|
||||
const result = await apiGet('/api/wip/hold-detail/summary', {
|
||||
params: { reason: REASON },
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch summary');
|
||||
}
|
||||
|
||||
async function fetchDistribution(signal) {
|
||||
const result = await apiGet('/api/wip/hold-detail/distribution', {
|
||||
params: { reason: REASON },
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch distribution');
|
||||
}
|
||||
|
||||
async function fetchLots(signal) {
|
||||
const params = {
|
||||
reason: REASON,
|
||||
page: page.value,
|
||||
per_page: pagination.value.perPage || 50,
|
||||
};
|
||||
|
||||
if (filters.workcenter) {
|
||||
params.workcenter = filters.workcenter;
|
||||
}
|
||||
if (filters.package) {
|
||||
params.package = filters.package;
|
||||
}
|
||||
if (filters.ageRange) {
|
||||
params.age_range = filters.ageRange;
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/wip/hold-detail/lots', {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch lots');
|
||||
}
|
||||
|
||||
const holdType = computed(() => {
|
||||
if (!REASON) {
|
||||
return 'quality';
|
||||
}
|
||||
return NON_QUALITY_HOLD_REASON_SET.has(REASON) ? 'non-quality' : 'quality';
|
||||
});
|
||||
|
||||
const holdTypeLabel = computed(() => (holdType.value === 'quality' ? '品質異常' : '非品質異常'));
|
||||
|
||||
const headerStyle = computed(() => ({
|
||||
'--header-gradient': holdType.value === 'quality'
|
||||
? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)'
|
||||
: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
|
||||
}));
|
||||
|
||||
const filterText = computed(() => {
|
||||
const parts = [];
|
||||
if (filters.workcenter) {
|
||||
parts.push(`Workcenter=${filters.workcenter}`);
|
||||
}
|
||||
if (filters.package) {
|
||||
parts.push(`Package=${filters.package}`);
|
||||
}
|
||||
if (filters.ageRange) {
|
||||
parts.push(`Age=${filters.ageRange}天`);
|
||||
}
|
||||
return parts.join(', ');
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() => Boolean(filterText.value));
|
||||
|
||||
const { createAbortSignal, clearAbortController, resetAutoRefresh, triggerRefresh } = useAutoRefresh({
|
||||
onRefresh: () => loadAllData(false),
|
||||
autoStart: true,
|
||||
});
|
||||
|
||||
async function loadLots() {
|
||||
lotsLoading.value = true;
|
||||
lotsError.value = '';
|
||||
loadError.value = '';
|
||||
refreshing.value = true;
|
||||
|
||||
const signal = createAbortSignal('hold-detail-lots');
|
||||
|
||||
try {
|
||||
const result = await fetchLots(signal);
|
||||
lots.value = Array.isArray(result?.lots) ? result.lots : [];
|
||||
pagination.value = {
|
||||
page: Number(result?.pagination?.page || 1),
|
||||
perPage: Number(result?.pagination?.perPage || 50),
|
||||
total: Number(result?.pagination?.total || 0),
|
||||
totalPages: Number(result?.pagination?.totalPages || 1),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
lotsError.value = error?.message || '載入 Lot 資料失敗';
|
||||
} finally {
|
||||
lotsLoading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllData(showOverlay = true) {
|
||||
clearAbortController('hold-detail-lots');
|
||||
const signal = createAbortSignal('hold-detail-all');
|
||||
|
||||
if (showOverlay) {
|
||||
initialLoading.value = true;
|
||||
}
|
||||
|
||||
loadError.value = '';
|
||||
lotsError.value = '';
|
||||
refreshing.value = true;
|
||||
|
||||
try {
|
||||
const [summaryData, distributionData, lotsData] = await Promise.all([
|
||||
fetchSummary(signal),
|
||||
fetchDistribution(signal),
|
||||
fetchLots(signal),
|
||||
]);
|
||||
|
||||
summary.value = summaryData;
|
||||
distribution.value = distributionData;
|
||||
lots.value = Array.isArray(lotsData?.lots) ? lotsData.lots : [];
|
||||
pagination.value = {
|
||||
page: Number(lotsData?.pagination?.page || 1),
|
||||
perPage: Number(lotsData?.pagination?.perPage || 50),
|
||||
total: Number(lotsData?.pagination?.total || 0),
|
||||
totalPages: Number(lotsData?.pagination?.totalPages || 1),
|
||||
};
|
||||
|
||||
lastUpdate.value = `Last Update: ${new Date().toLocaleString('zh-TW')}`;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
loadError.value = error?.message || '載入資料失敗';
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAgeFilter(range) {
|
||||
filters.ageRange = filters.ageRange === range ? null : range;
|
||||
page.value = 1;
|
||||
void loadLots();
|
||||
}
|
||||
|
||||
function toggleWorkcenterFilter(name) {
|
||||
filters.workcenter = filters.workcenter === name ? null : name;
|
||||
page.value = 1;
|
||||
void loadLots();
|
||||
}
|
||||
|
||||
function togglePackageFilter(name) {
|
||||
filters.package = filters.package === name ? null : name;
|
||||
page.value = 1;
|
||||
void loadLots();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.ageRange = null;
|
||||
filters.workcenter = null;
|
||||
filters.package = null;
|
||||
page.value = 1;
|
||||
void loadLots();
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value <= 1) {
|
||||
return;
|
||||
}
|
||||
page.value -= 1;
|
||||
void loadLots();
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page.value >= pagination.value.totalPages) {
|
||||
return;
|
||||
}
|
||||
page.value += 1;
|
||||
void loadLots();
|
||||
}
|
||||
|
||||
async function manualRefresh() {
|
||||
await triggerRefresh({ resetTimer: true, force: true });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!REASON) {
|
||||
window.location.replace('/wip-overview');
|
||||
return;
|
||||
}
|
||||
void loadAllData(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard hold-detail-page">
|
||||
<header class="header" :style="headerStyle">
|
||||
<div class="header-left">
|
||||
<a href="/wip-overview" class="btn btn-back">← WIP Overview</a>
|
||||
<h1>Hold Detail: {{ REASON }}</h1>
|
||||
<span class="hold-type-badge">{{ holdTypeLabel }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="last-update">
|
||||
<span class="refresh-indicator" :class="{ active: refreshing }"></span>
|
||||
<span>{{ lastUpdate }}</span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-light" @click="manualRefresh">重新整理</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="loadError" class="error-banner">{{ loadError }}</p>
|
||||
|
||||
<SummaryCards :summary="summary" />
|
||||
|
||||
<section class="section-title">當站滯留天數分佈 (Age at Current Station)</section>
|
||||
<AgeDistribution
|
||||
:items="distribution?.byAge || []"
|
||||
:active-range="filters.ageRange"
|
||||
@toggle="toggleAgeFilter"
|
||||
/>
|
||||
|
||||
<section class="distribution-grid">
|
||||
<DistributionTable
|
||||
title="By Workcenter"
|
||||
:rows="distribution?.byWorkcenter || []"
|
||||
:active-name="filters.workcenter"
|
||||
@toggle="toggleWorkcenterFilter"
|
||||
/>
|
||||
<DistributionTable
|
||||
title="By Package"
|
||||
:rows="distribution?.byPackage || []"
|
||||
:active-name="filters.package"
|
||||
@toggle="togglePackageFilter"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<LotTable
|
||||
:lots="lots"
|
||||
:pagination="pagination"
|
||||
:loading="lotsLoading"
|
||||
:error-message="lotsError"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
:filter-text="filterText"
|
||||
@clear-filters="clearFilters"
|
||||
@prev-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="initialLoading" class="loading-overlay">
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
61
frontend/src/hold-detail/components/AgeDistribution.vue
Normal file
61
frontend/src/hold-detail/components/AgeDistribution.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeRange: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const cardRanges = ['0-1', '1-3', '3-7', '7+'];
|
||||
|
||||
const ageMap = computed(() => {
|
||||
const map = {};
|
||||
props.items.forEach((item) => {
|
||||
if (item?.range) {
|
||||
map[item.range] = item;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
function getCard(range) {
|
||||
return ageMap.value[range] || { lots: 0, qty: 0, percentage: 0 };
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString('zh-TW');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="age-distribution">
|
||||
<article
|
||||
v-for="range in cardRanges"
|
||||
:key="range"
|
||||
class="age-card"
|
||||
:class="{ active: activeRange === range }"
|
||||
@click="emit('toggle', range)"
|
||||
>
|
||||
<div class="age-label">{{ range }}天</div>
|
||||
<div class="age-stats">
|
||||
<div class="age-stat">
|
||||
<span class="label">Lots</span>
|
||||
<span class="value">{{ formatNumber(getCard(range).lots) }}</span>
|
||||
</div>
|
||||
<div class="age-stat">
|
||||
<span class="label">QTY</span>
|
||||
<span class="value">{{ formatNumber(getCard(range).qty) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="age-percentage">{{ getCard(range).percentage || 0 }}%</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
59
frontend/src/hold-detail/components/DistributionTable.vue
Normal file
59
frontend/src/hold-detail/components/DistributionTable.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString('zh-TW');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card distribution-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">{{ title }}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="dist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ title === 'By Workcenter' ? 'Workcenter' : 'Package' }}</th>
|
||||
<th>Lots</th>
|
||||
<th>QTY</th>
|
||||
<th>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="4" class="placeholder">No data</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
v-else
|
||||
:key="row.name"
|
||||
:class="{ active: activeName === row.name }"
|
||||
@click="emit('toggle', row.name)"
|
||||
>
|
||||
<td>{{ row.name || '-' }}</td>
|
||||
<td>{{ formatNumber(row.lots) }}</td>
|
||||
<td>{{ formatNumber(row.qty) }}</td>
|
||||
<td>{{ row.percentage || 0 }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
132
frontend/src/hold-detail/components/LotTable.vue
Normal file
132
frontend/src/hold-detail/components/LotTable.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Pagination from '../../wip-shared/components/Pagination.vue';
|
||||
|
||||
const props = defineProps({
|
||||
lots: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }),
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasActiveFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['clear-filters', 'prev-page', 'next-page']);
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '-') {
|
||||
return '-';
|
||||
}
|
||||
return Number(value).toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function formatAge(value) {
|
||||
if (value === null || value === undefined || value === '-') {
|
||||
return '-';
|
||||
}
|
||||
return `${value}天`;
|
||||
}
|
||||
|
||||
const tableInfo = computed(() => {
|
||||
const page = Number(props.pagination?.page || 1);
|
||||
const perPage = Number(props.pagination?.perPage || 50);
|
||||
const total = Number(props.pagination?.total || 0);
|
||||
|
||||
if (total <= 0) {
|
||||
return 'No data';
|
||||
}
|
||||
|
||||
const start = (page - 1) * perPage + 1;
|
||||
const end = Math.min(page * perPage, total);
|
||||
return `顯示 ${start} - ${end} / ${formatNumber(total)}`;
|
||||
});
|
||||
|
||||
const pageInfo = computed(() => {
|
||||
const page = Number(props.pagination?.page || 1);
|
||||
const totalPages = Number(props.pagination?.totalPages || 1);
|
||||
return `Page ${page} / ${totalPages}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-section">
|
||||
<div class="table-header">
|
||||
<div class="table-title">Lot Details</div>
|
||||
<div v-if="hasActiveFilters" class="filter-indicator">
|
||||
<span>篩選: {{ filterText }}</span>
|
||||
<span class="clear-btn" @click="emit('clear-filters')">×</span>
|
||||
</div>
|
||||
<div class="table-info">{{ tableInfo }}</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="lot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>LOTID</th>
|
||||
<th>WORKORDER</th>
|
||||
<th>QTY</th>
|
||||
<th>Package</th>
|
||||
<th>Workcenter</th>
|
||||
<th>Spec</th>
|
||||
<th>Age</th>
|
||||
<th>Hold By</th>
|
||||
<th>Dept</th>
|
||||
<th>Hold Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="10" class="placeholder">Loading...</td>
|
||||
</tr>
|
||||
<tr v-else-if="errorMessage">
|
||||
<td colspan="10" class="placeholder">{{ errorMessage }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="lots.length === 0">
|
||||
<td colspan="10" class="placeholder">No data</td>
|
||||
</tr>
|
||||
<tr v-for="lot in lots" v-else :key="lot.lotId">
|
||||
<td>{{ lot.lotId || '-' }}</td>
|
||||
<td>{{ lot.workorder || '-' }}</td>
|
||||
<td>{{ formatNumber(lot.qty) }}</td>
|
||||
<td>{{ lot.package || '-' }}</td>
|
||||
<td>{{ lot.workcenter || '-' }}</td>
|
||||
<td>{{ lot.spec || '-' }}</td>
|
||||
<td>{{ formatAge(lot.age) }}</td>
|
||||
<td>{{ lot.holdBy || '-' }}</td>
|
||||
<td>{{ lot.dept || '-' }}</td>
|
||||
<td>{{ lot.holdComment || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:visible="Number(pagination.totalPages || 1) > 1"
|
||||
:page="Number(pagination.page || 1)"
|
||||
:total-pages="Number(pagination.totalPages || 1)"
|
||||
:info-text="pageInfo"
|
||||
@prev="emit('prev-page')"
|
||||
@next="emit('next-page')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
47
frontend/src/hold-detail/components/SummaryCards.vue
Normal file
47
frontend/src/hold-detail/components/SummaryCards.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '-') {
|
||||
return '-';
|
||||
}
|
||||
return Number(value).toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function formatAge(value) {
|
||||
if (value === null || value === undefined || value === '-') {
|
||||
return '-';
|
||||
}
|
||||
return `${value}天`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="summary-row hold-summary-row">
|
||||
<article class="summary-card">
|
||||
<div class="summary-label">Total Lots</div>
|
||||
<div class="summary-value">{{ formatNumber(summary?.totalLots) }}</div>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<div class="summary-label">Total QTY</div>
|
||||
<div class="summary-value">{{ formatNumber(summary?.totalQty) }}</div>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<div class="summary-label">平均當站滯留</div>
|
||||
<div class="summary-value small">{{ formatAge(summary?.avgAge) }}</div>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<div class="summary-label">最久當站滯留</div>
|
||||
<div class="summary-value small">{{ formatAge(summary?.maxAge) }}</div>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<div class="summary-label">影響站群</div>
|
||||
<div class="summary-value">{{ formatNumber(summary?.workcenterCount) }}</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
12
frontend/src/hold-detail/index.html
Normal file
12
frontend/src/hold-detail/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hold Detail</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = '<tr><td colspan="4" class="placeholder">No data</td></tr>';
|
||||
} else {
|
||||
wcBody.innerHTML = data.byWorkcenter.map(item => `
|
||||
<tr data-workcenter="${escapeHtml(safeText(item.name))}" onclick="toggleWorkcenterFilter('${jsSingleQuote(item.name)}')" class="${state.filters.workcenter === item.name ? 'active' : ''}">
|
||||
<td>${escapeHtml(safeText(item.name))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.lots))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.qty))}</td>
|
||||
<td>${escapeHtml(safeText(item.percentage, 0))}%</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Package table
|
||||
const pkgBody = document.getElementById('packageBody');
|
||||
if (data.byPackage.length === 0) {
|
||||
pkgBody.innerHTML = '<tr><td colspan="4" class="placeholder">No data</td></tr>';
|
||||
} else {
|
||||
pkgBody.innerHTML = data.byPackage.map(item => `
|
||||
<tr data-package="${escapeHtml(safeText(item.name))}" onclick="togglePackageFilter('${jsSingleQuote(item.name)}')" class="${state.filters.package === item.name ? 'active' : ''}">
|
||||
<td>${escapeHtml(safeText(item.name))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.lots))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.qty))}</td>
|
||||
<td>${escapeHtml(safeText(item.percentage, 0))}%</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function renderLots(data) {
|
||||
const tbody = document.getElementById('lotBody');
|
||||
const lots = data.lots;
|
||||
|
||||
if (lots.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="placeholder">No data</td></tr>';
|
||||
document.getElementById('tableInfo').textContent = 'No data';
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = lots.map(lot => `
|
||||
<tr>
|
||||
<td>${escapeHtml(safeText(lot.lotId))}</td>
|
||||
<td>${escapeHtml(safeText(lot.workorder))}</td>
|
||||
<td>${escapeHtml(formatNumber(lot.qty))}</td>
|
||||
<td>${escapeHtml(safeText(lot.package))}</td>
|
||||
<td>${escapeHtml(safeText(lot.workcenter))}</td>
|
||||
<td>${escapeHtml(safeText(lot.spec))}</td>
|
||||
<td>${escapeHtml(safeText(lot.age))}天</td>
|
||||
<td>${escapeHtml(safeText(lot.holdBy))}</td>
|
||||
<td>${escapeHtml(safeText(lot.dept))}</td>
|
||||
<td>${escapeHtml(safeText(lot.holdComment))}</td>
|
||||
</tr>
|
||||
`).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 = '<tr><td colspan="10" class="placeholder">Loading...</td></tr>';
|
||||
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 = '<tr><td colspan="10" class="placeholder">Error loading data</td></tr>';
|
||||
} 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');
|
||||
|
||||
263
frontend/src/hold-detail/style.css
Normal file
263
frontend/src/hold-detail/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
328
frontend/src/wip-detail/App.vue
Normal file
328
frontend/src/wip-detail/App.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import { buildWipDetailQueryParams } from '../core/wip-derive.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
|
||||
import FilterPanel from './components/FilterPanel.vue';
|
||||
import LotDetailPanel from './components/LotDetailPanel.vue';
|
||||
import LotTable from './components/LotTable.vue';
|
||||
import SummaryCards from './components/SummaryCards.vue';
|
||||
|
||||
const API_TIMEOUT = 60000;
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
const workcenter = ref('');
|
||||
const page = ref(1);
|
||||
const filters = reactive({
|
||||
workorder: '',
|
||||
lotid: '',
|
||||
package: '',
|
||||
type: '',
|
||||
});
|
||||
const activeStatusFilter = ref(null);
|
||||
|
||||
const detailData = ref(null);
|
||||
const loading = ref(true);
|
||||
const tableLoading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const refreshSuccess = ref(false);
|
||||
const refreshError = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const selectedLotId = ref('');
|
||||
|
||||
function unwrapApiResult(result, fallbackMessage) {
|
||||
if (result?.success) {
|
||||
return result.data;
|
||||
}
|
||||
if (result?.success === false) {
|
||||
throw new Error(result.error || fallbackMessage);
|
||||
}
|
||||
if (result?.data !== undefined) {
|
||||
return result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getUrlParam(name) {
|
||||
return new URLSearchParams(window.location.search).get(name)?.trim() || '';
|
||||
}
|
||||
|
||||
function updateUrlState() {
|
||||
if (!workcenter.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('workcenter', workcenter.value);
|
||||
|
||||
if (filters.workorder) {
|
||||
params.set('workorder', filters.workorder);
|
||||
}
|
||||
if (filters.lotid) {
|
||||
params.set('lotid', filters.lotid);
|
||||
}
|
||||
if (filters.package) {
|
||||
params.set('package', filters.package);
|
||||
}
|
||||
if (filters.type) {
|
||||
params.set('type', filters.type);
|
||||
}
|
||||
|
||||
window.history.replaceState({}, '', `/wip-detail?${params.toString()}`);
|
||||
}
|
||||
|
||||
async function fetchWorkcenters(signal) {
|
||||
const result = await apiGet('/api/wip/meta/workcenters', {
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch workcenters');
|
||||
}
|
||||
|
||||
async function fetchDetail(signal) {
|
||||
if (!workcenter.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = buildWipDetailQueryParams({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
filters,
|
||||
statusFilter: activeStatusFilter.value,
|
||||
});
|
||||
|
||||
const result = await apiGet(`/api/wip/detail/${encodeURIComponent(workcenter.value)}`, {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch detail');
|
||||
}
|
||||
|
||||
function showRefreshSuccess() {
|
||||
refreshSuccess.value = true;
|
||||
setTimeout(() => {
|
||||
refreshSuccess.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
const { createAbortSignal, triggerRefresh, startAutoRefresh, resetAutoRefresh } = useAutoRefresh({
|
||||
onRefresh: () => loadAllData(false),
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
async function loadAllData(showOverlay = true) {
|
||||
if (!workcenter.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signal = createAbortSignal('wip-detail-all');
|
||||
|
||||
if (showOverlay) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
tableLoading.value = true;
|
||||
refreshing.value = true;
|
||||
refreshError.value = false;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
detailData.value = await fetchDetail(signal);
|
||||
showRefreshSuccess();
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
refreshError.value = true;
|
||||
errorMessage.value = error?.message || '載入資料失敗';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
tableLoading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTableOnly() {
|
||||
if (!workcenter.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signal = createAbortSignal('wip-detail-table');
|
||||
tableLoading.value = true;
|
||||
refreshing.value = true;
|
||||
|
||||
try {
|
||||
detailData.value = await fetchDetail(signal);
|
||||
showRefreshSuccess();
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
refreshError.value = true;
|
||||
errorMessage.value = error?.message || '載入表格失敗';
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
return workcenter.value ? `WIP Detail - ${workcenter.value}` : 'WIP Detail';
|
||||
});
|
||||
|
||||
const lastUpdate = computed(() => {
|
||||
return detailData.value?.sys_date ? `Last Update: ${detailData.value.sys_date}` : '';
|
||||
});
|
||||
|
||||
const summary = computed(() => detailData.value?.summary || null);
|
||||
const tableData = computed(() => ({
|
||||
lots: detailData.value?.lots || [],
|
||||
specs: detailData.value?.specs || [],
|
||||
pagination: detailData.value?.pagination || { page: 1, page_size: PAGE_SIZE, total_count: 0, total_pages: 1 },
|
||||
}));
|
||||
|
||||
function updateFilters(nextFilters) {
|
||||
filters.workorder = nextFilters.workorder || '';
|
||||
filters.lotid = nextFilters.lotid || '';
|
||||
filters.package = nextFilters.package || '';
|
||||
filters.type = nextFilters.type || '';
|
||||
}
|
||||
|
||||
function applyFilters(nextFilters) {
|
||||
updateFilters(nextFilters);
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
updateFilters({ workorder: '', lotid: '', package: '', type: '' });
|
||||
activeStatusFilter.value = null;
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function toggleStatusFilter(status) {
|
||||
activeStatusFilter.value = activeStatusFilter.value === status ? null : status;
|
||||
page.value = 1;
|
||||
selectedLotId.value = '';
|
||||
void loadTableOnly();
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value <= 1) {
|
||||
return;
|
||||
}
|
||||
page.value -= 1;
|
||||
selectedLotId.value = '';
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
const totalPages = Number(tableData.value.pagination?.total_pages || 1);
|
||||
if (page.value >= totalPages) {
|
||||
return;
|
||||
}
|
||||
page.value += 1;
|
||||
selectedLotId.value = '';
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function openLotDetail(lotId) {
|
||||
selectedLotId.value = lotId;
|
||||
}
|
||||
|
||||
function closeLotDetail() {
|
||||
selectedLotId.value = '';
|
||||
}
|
||||
|
||||
async function manualRefresh() {
|
||||
await triggerRefresh({ resetTimer: true, force: true });
|
||||
}
|
||||
|
||||
async function initializePage() {
|
||||
workcenter.value = getUrlParam('workcenter');
|
||||
|
||||
filters.workorder = getUrlParam('workorder');
|
||||
filters.lotid = getUrlParam('lotid');
|
||||
filters.package = getUrlParam('package');
|
||||
filters.type = getUrlParam('type');
|
||||
|
||||
if (!workcenter.value) {
|
||||
const signal = createAbortSignal('wip-detail-init');
|
||||
try {
|
||||
const workcenters = await fetchWorkcenters(signal);
|
||||
if (Array.isArray(workcenters) && workcenters.length > 0) {
|
||||
workcenter.value = workcenters[0].name;
|
||||
updateUrlState();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
errorMessage.value = error?.message || '無法取得工站列表';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!workcenter.value) {
|
||||
loading.value = false;
|
||||
errorMessage.value = errorMessage.value || 'No workcenter available';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadAllData(true);
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
void initializePage();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard wip-detail-page">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<a href="/wip-overview" class="btn btn-back">← Overview</a>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="last-update">
|
||||
<span class="refresh-indicator" :class="{ active: refreshing }"></span>
|
||||
<span class="refresh-success" :class="{ active: refreshSuccess }">✓</span>
|
||||
<span class="refresh-error" :class="{ active: refreshError }"></span>
|
||||
<span>{{ lastUpdate }}</span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-light" @click="manualRefresh">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<FilterPanel :filters="filters" @apply="applyFilters" @clear="clearFilters" />
|
||||
|
||||
<SummaryCards
|
||||
:summary="summary"
|
||||
:active-status="activeStatusFilter"
|
||||
@toggle="toggleStatusFilter"
|
||||
/>
|
||||
|
||||
<LotTable
|
||||
:data="tableData"
|
||||
:loading="tableLoading"
|
||||
:active-status="activeStatusFilter"
|
||||
:selected-lot-id="selectedLotId"
|
||||
@select-lot="openLotDetail"
|
||||
@prev-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
/>
|
||||
|
||||
<LotDetailPanel :lot-id="selectedLotId" @close="closeLotDetail" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
110
frontend/src/wip-detail/components/FilterPanel.vue
Normal file
110
frontend/src/wip-detail/components/FilterPanel.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
import { apiGet } from '../../core/api.js';
|
||||
import { useAutocomplete } from '../../wip-shared/composables/useAutocomplete.js';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['apply', 'clear']);
|
||||
|
||||
const draft = reactive({
|
||||
workorder: '',
|
||||
lotid: '',
|
||||
package: '',
|
||||
type: '',
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters,
|
||||
(nextFilters) => {
|
||||
draft.workorder = nextFilters.workorder || '';
|
||||
draft.lotid = nextFilters.lotid || '';
|
||||
draft.package = nextFilters.package || '';
|
||||
draft.type = nextFilters.type || '';
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const { ensureField, handleInput, handleFocus, handleBlur, selectItem } = useAutocomplete({
|
||||
getFilters: () => ({ ...draft }),
|
||||
request: (url, options) => apiGet(url, options),
|
||||
debounceMs: 300,
|
||||
});
|
||||
|
||||
const fields = [
|
||||
{ key: 'workorder', label: 'WORKORDER', placeholder: 'Search...' },
|
||||
{ key: 'lotid', label: 'LOT ID', placeholder: 'Search...' },
|
||||
{ key: 'package', label: 'PACKAGE', placeholder: 'Search...' },
|
||||
{ key: 'type', label: 'TYPE', placeholder: 'Search...' },
|
||||
];
|
||||
|
||||
function getFieldState(field) {
|
||||
return ensureField(field);
|
||||
}
|
||||
|
||||
function onInput(field, event) {
|
||||
draft[field] = event.target.value;
|
||||
handleInput(field, draft[field]);
|
||||
}
|
||||
|
||||
function onSelect(field, value) {
|
||||
draft[field] = selectItem(field, value);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
emit('apply', { ...draft });
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
draft.workorder = '';
|
||||
draft.lotid = '';
|
||||
draft.package = '';
|
||||
draft.type = '';
|
||||
emit('clear');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="filters">
|
||||
<div v-for="field in fields" :key="field.key" class="filter-group">
|
||||
<label>{{ field.label }}</label>
|
||||
<div class="autocomplete-container">
|
||||
<input
|
||||
type="text"
|
||||
:value="draft[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
autocomplete="off"
|
||||
@input="onInput(field.key, $event)"
|
||||
@focus="handleFocus(field.key)"
|
||||
@blur="handleBlur(field.key)"
|
||||
@keydown.enter.prevent="applyFilters"
|
||||
/>
|
||||
<div class="autocomplete-dropdown" :class="{ show: getFieldState(field.key).open }">
|
||||
<div
|
||||
v-for="item in getFieldState(field.key).items"
|
||||
:key="item"
|
||||
class="autocomplete-item"
|
||||
@mousedown.prevent="onSelect(field.key, item)"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
<div
|
||||
v-if="!getFieldState(field.key).loading && getFieldState(field.key).open && getFieldState(field.key).items.length === 0"
|
||||
class="autocomplete-empty"
|
||||
>
|
||||
No results
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-primary" @click="applyFilters">Apply</button>
|
||||
<button type="button" class="btn-secondary" @click="clearFilters">Clear</button>
|
||||
</section>
|
||||
</template>
|
||||
198
frontend/src/wip-detail/components/LotDetailPanel.vue
Normal file
198
frontend/src/wip-detail/components/LotDetailPanel.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { apiGet } from '../../core/api.js';
|
||||
|
||||
const props = defineProps({
|
||||
lotId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const detail = ref(null);
|
||||
|
||||
function unwrapApiResult(result, fallbackMessage) {
|
||||
if (result?.success) {
|
||||
return result.data;
|
||||
}
|
||||
if (result?.success === false) {
|
||||
throw new Error(result.error || fallbackMessage);
|
||||
}
|
||||
if (result?.data !== undefined) {
|
||||
return result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadLotDetail(lotId) {
|
||||
if (!lotId) {
|
||||
detail.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const result = await apiGet(`/api/wip/lot/${encodeURIComponent(lotId)}`, {
|
||||
timeout: 60000,
|
||||
});
|
||||
detail.value = unwrapApiResult(result, 'Failed to fetch lot detail');
|
||||
} catch (error) {
|
||||
detail.value = null;
|
||||
errorMessage.value = error?.message || '載入失敗';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.lotId,
|
||||
(lotId) => {
|
||||
if (!lotId) {
|
||||
detail.value = null;
|
||||
return;
|
||||
}
|
||||
void loadLotDetail(lotId);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const labels = computed(() => detail.value?.fieldLabels || {});
|
||||
|
||||
function getLabel(key) {
|
||||
return labels.value[key] || key;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
}
|
||||
return Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-TW') : String(value);
|
||||
}
|
||||
|
||||
function fieldValue(key) {
|
||||
const value = detail.value?.[key];
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return formatNumber(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function fieldClass(key) {
|
||||
if (key !== 'wipStatus') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const status = String(detail.value?.[key] || '').toLowerCase();
|
||||
return `status-${status}`;
|
||||
}
|
||||
|
||||
function hasHoldSection() {
|
||||
return detail.value?.wipStatus === 'HOLD' || Number(detail.value?.holdCount || 0) > 0;
|
||||
}
|
||||
|
||||
const basicFields = ['lotId', 'workorder', 'wipStatus', 'status', 'qty', 'qty2', 'ageByDays', 'priority'];
|
||||
const productFields = ['product', 'productLine', 'packageLef', 'pjType', 'pjFunction', 'bop', 'dateCode', 'produceRegion'];
|
||||
const processFields = ['workcenterGroup', 'workcenter', 'spec', 'specSequence', 'workflow', 'equipment', 'equipmentCount', 'location'];
|
||||
const materialFields = ['waferLotId', 'waferPn', 'waferLotPrefix', 'leadframeName', 'leadframeOption', 'compoundName', 'dieConsumption', 'uts'];
|
||||
const holdFields = ['holdReason', 'holdCount', 'holdEmp', 'holdDept', 'holdComment', 'releaseTime', 'releaseEmp', 'releaseComment'];
|
||||
const ncrFields = ['ncrId', 'ncrDate'];
|
||||
const commentFields = ['comment', 'commentDate', 'commentEmp', 'futureHoldComment'];
|
||||
const otherFields = ['owner', 'startDate', 'tmttRemaining', 'dataUpdateDate'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="lotId" class="lot-detail-panel show">
|
||||
<div class="lot-detail-header">
|
||||
<div class="lot-detail-title">
|
||||
Lot Detail -
|
||||
<span class="lot-id">{{ lotId }}</span>
|
||||
</div>
|
||||
<button type="button" class="lot-detail-close" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="lot-detail-content">
|
||||
<div v-if="loading" class="lot-detail-loading">
|
||||
<span class="loading-spinner"></span>
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="lot-detail-loading error">{{ errorMessage }}</div>
|
||||
|
||||
<div v-else-if="detail" class="lot-detail-grid">
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">基本資訊</div>
|
||||
<div v-for="field in basicFields" :key="field" class="lot-detail-field">
|
||||
<span class="lot-detail-label">{{ getLabel(field) }}</span>
|
||||
<span class="lot-detail-value" :class="fieldClass(field)">{{ fieldValue(field) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">產品資訊</div>
|
||||
<div v-for="field in productFields" :key="field" class="lot-detail-field">
|
||||
<span class="lot-detail-label">{{ getLabel(field) }}</span>
|
||||
<span class="lot-detail-value">{{ fieldValue(field) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">製程資訊</div>
|
||||
<div v-for="field in processFields" :key="field" class="lot-detail-field">
|
||||
<span class="lot-detail-label">{{ getLabel(field) }}</span>
|
||||
<span class="lot-detail-value">{{ fieldValue(field) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">物料資訊</div>
|
||||
<div v-for="field in materialFields" :key="field" class="lot-detail-field">
|
||||
<span class="lot-detail-label">{{ getLabel(field) }}</span>
|
||||
<span class="lot-detail-value">{{ fieldValue(field) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasHoldSection()" class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">Hold 資訊</div>
|
||||
<div v-for="field in holdFields" :key="field" class="lot-detail-field">
|
||||
<span class="lot-detail-label">{{ getLabel(field) }}</span>
|
||||
<span class="lot-detail-value">{{ fieldValue(field) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.ncrId" class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">NCR 資訊</div>
|
||||
<div v-for="field in ncrFields" :key="field" class="lot-detail-field">
|
||||
<span class="lot-detail-label">{{ getLabel(field) }}</span>
|
||||
<span class="lot-detail-value">{{ fieldValue(field) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">備註資訊</div>
|
||||
<div v-for="field in commentFields" :key="field" class="lot-detail-field">
|
||||
<span class="lot-detail-label">{{ getLabel(field) }}</span>
|
||||
<span class="lot-detail-value">{{ fieldValue(field) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">其他資訊</div>
|
||||
<div v-for="field in otherFields" :key="field" class="lot-detail-field">
|
||||
<span class="lot-detail-label">{{ getLabel(field) }}</span>
|
||||
<span class="lot-detail-value">{{ fieldValue(field) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
143
frontend/src/wip-detail/components/LotTable.vue
Normal file
143
frontend/src/wip-detail/components/LotTable.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Pagination from '../../wip-shared/components/Pagination.vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
lots: [],
|
||||
specs: [],
|
||||
pagination: { page: 1, page_size: 100, total_count: 0, total_pages: 1 },
|
||||
}),
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
activeStatus: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
selectedLotId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-lot', 'prev-page', 'next-page']);
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '-') {
|
||||
return '-';
|
||||
}
|
||||
return Number(value).toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const normalized = String(status || 'QUEUE').toLowerCase();
|
||||
return `wip-status-${normalized}`;
|
||||
}
|
||||
|
||||
function statusText(lot) {
|
||||
if (lot?.wipStatus === 'HOLD' && lot?.holdReason) {
|
||||
return `HOLD (${lot.holdReason})`;
|
||||
}
|
||||
return lot?.wipStatus || 'QUEUE';
|
||||
}
|
||||
|
||||
const tableTitle = computed(() => {
|
||||
if (!props.activeStatus) {
|
||||
return 'Lot Details';
|
||||
}
|
||||
|
||||
if (props.activeStatus === 'quality-hold') {
|
||||
return 'Lot Details - 品質異常 Hold Only';
|
||||
}
|
||||
if (props.activeStatus === 'non-quality-hold') {
|
||||
return 'Lot Details - 非品質異常 Hold Only';
|
||||
}
|
||||
|
||||
return `Lot Details - ${props.activeStatus.toUpperCase()} Only`;
|
||||
});
|
||||
|
||||
const tableInfo = computed(() => {
|
||||
const pagination = props.data.pagination || {};
|
||||
const total = Number(pagination.total_count || 0);
|
||||
|
||||
if (total <= 0) {
|
||||
return 'No data';
|
||||
}
|
||||
|
||||
const page = Number(pagination.page || 1);
|
||||
const pageSize = Number(pagination.page_size || 100);
|
||||
const start = (page - 1) * pageSize + 1;
|
||||
const end = Math.min(page * pageSize, total);
|
||||
return `Showing ${start} - ${end} of ${formatNumber(total)}`;
|
||||
});
|
||||
|
||||
const pageInfo = computed(() => {
|
||||
const pagination = props.data.pagination || {};
|
||||
return `Page ${pagination.page || 1} / ${pagination.total_pages || 1}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-section">
|
||||
<div class="table-header">
|
||||
<div class="table-title">{{ tableTitle }}</div>
|
||||
<div class="table-info">{{ tableInfo }}</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div v-if="loading" class="placeholder">Loading...</div>
|
||||
<div v-else-if="!data.lots || data.lots.length === 0" class="placeholder">No data available</div>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="fixed-col">LOT ID</th>
|
||||
<th class="fixed-col">Equipment</th>
|
||||
<th class="fixed-col">WIP Status</th>
|
||||
<th class="fixed-col">Package</th>
|
||||
<th v-for="spec in data.specs" :key="spec" class="spec-col">{{ spec }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="lot in data.lots" :key="lot.lotId">
|
||||
<td class="fixed-col">
|
||||
<button
|
||||
type="button"
|
||||
class="lot-id-link"
|
||||
:class="{ active: selectedLotId === lot.lotId }"
|
||||
@click="emit('select-lot', lot.lotId)"
|
||||
>
|
||||
{{ lot.lotId || '-' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="fixed-col">{{ lot.equipment || '-' }}</td>
|
||||
<td class="fixed-col" :class="statusClass(lot.wipStatus)">{{ statusText(lot) }}</td>
|
||||
<td class="fixed-col">{{ lot.package || '-' }}</td>
|
||||
<td
|
||||
v-for="spec in data.specs"
|
||||
:key="`${lot.lotId}-${spec}`"
|
||||
class="spec-cell"
|
||||
:class="{ 'has-data': lot.spec === spec }"
|
||||
>
|
||||
<template v-if="lot.spec === spec">{{ formatNumber(lot.qty) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:visible="Number(data.pagination?.total_pages || 1) > 1"
|
||||
:page="Number(data.pagination?.page || 1)"
|
||||
:total-pages="Number(data.pagination?.total_pages || 1)"
|
||||
:info-text="pageInfo"
|
||||
@prev="emit('prev-page')"
|
||||
@next="emit('next-page')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
48
frontend/src/wip-detail/components/SummaryCards.vue
Normal file
48
frontend/src/wip-detail/components/SummaryCards.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
activeStatus: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const cards = [
|
||||
{ key: 'run', label: 'RUN', className: 'status-run', valueKey: 'runLots' },
|
||||
{ key: 'queue', label: 'QUEUE', className: 'status-queue', valueKey: 'queueLots' },
|
||||
{ key: 'quality-hold', label: '品質異常', className: 'status-quality-hold', valueKey: 'qualityHoldLots' },
|
||||
{ key: 'non-quality-hold', label: '非品質異常', className: 'status-non-quality-hold', valueKey: 'nonQualityHoldLots' },
|
||||
];
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '-') {
|
||||
return '-';
|
||||
}
|
||||
return Number(value).toLocaleString('zh-TW');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="summary-row detail-summary-row" :class="{ filtering: activeStatus }">
|
||||
<article class="summary-card">
|
||||
<div class="summary-label">Total Lots</div>
|
||||
<div class="summary-value">{{ formatNumber(summary?.totalLots) }}</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
class="summary-card"
|
||||
:class="[card.className, { active: activeStatus === card.key }]"
|
||||
@click="emit('toggle', card.key)"
|
||||
>
|
||||
<div class="summary-label">{{ card.label }}</div>
|
||||
<div class="summary-value">{{ formatNumber(summary?.[card.valueKey]) }}</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
12
frontend/src/wip-detail/index.html
Normal file
12
frontend/src/wip-detail/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WIP Detail Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = '<div class="placeholder">No data available</div>';
|
||||
document.getElementById('tableInfo').textContent = 'No data';
|
||||
document.getElementById('pagination').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const specs = data.specs || [];
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
// Fixed columns
|
||||
html += '<th class="fixed-col">LOT ID</th>';
|
||||
html += '<th class="fixed-col">Equipment</th>';
|
||||
html += '<th class="fixed-col">WIP Status</th>';
|
||||
html += '<th class="fixed-col">Package</th>';
|
||||
|
||||
// Spec columns
|
||||
specs.forEach(spec => {
|
||||
html += `<th class="spec-col">${spec}</th>`;
|
||||
});
|
||||
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
data.lots.forEach(lot => {
|
||||
html += '<tr>';
|
||||
|
||||
// Fixed columns - LOT ID is clickable
|
||||
const lotIdDisplay = lot.lotId
|
||||
? `<span class="lot-id-link" onclick="showLotDetail('${lot.lotId}')">${lot.lotId}</span>`
|
||||
: '-';
|
||||
html += `<td class="fixed-col">${lotIdDisplay}</td>`;
|
||||
html += `<td class="fixed-col">${lot.equipment || '<span style="color: var(--muted);">-</span>'}</td>`;
|
||||
|
||||
// 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 += `<td class="fixed-col ${statusClass}">${statusText}</td>`;
|
||||
|
||||
html += `<td class="fixed-col">${lot.package || '-'}</td>`;
|
||||
|
||||
// Spec columns - show QTY in matching spec column
|
||||
specs.forEach(spec => {
|
||||
if (lot.spec === spec) {
|
||||
html += `<td class="spec-cell has-data">${formatNumber(lot.qty)}</td>`;
|
||||
} else {
|
||||
html += '<td class="spec-cell"></td>';
|
||||
}
|
||||
});
|
||||
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
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 = '<option value="">All</option>';
|
||||
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 = '<div class="autocomplete-empty">No results</div>';
|
||||
dropdown.classList.add('show');
|
||||
return;
|
||||
}
|
||||
dropdown.innerHTML = items.map(item =>
|
||||
`<div class="autocomplete-item" data-value="${item}">${item}</div>`
|
||||
).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 = '<div class="autocomplete-loading">Searching...</div>';
|
||||
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 = '<div class="placeholder">Loading...</div>';
|
||||
|
||||
// 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 = '<div class="placeholder">Error loading data</div>';
|
||||
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 = `
|
||||
<div class="lot-detail-loading">
|
||||
<span class="loading-spinner"></span>Loading...
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<div class="lot-detail-loading" style="color: var(--danger);">
|
||||
載入失敗:${error.message || '未知錯誤'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLotDetail(data) {
|
||||
const labels = data.fieldLabels || {};
|
||||
|
||||
// Helper to format value
|
||||
const formatValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '<span class="empty">-</span>';
|
||||
}
|
||||
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 `
|
||||
<div class="lot-detail-field">
|
||||
<span class="lot-detail-label">${label}</span>
|
||||
<span class="lot-detail-value ${valueClass}">${formatValue(value)}</span>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const html = `
|
||||
<div class="lot-detail-grid">
|
||||
<!-- Basic Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">基本資訊</div>
|
||||
${field('lotId')}
|
||||
${field('workorder')}
|
||||
${field('wipStatus')}
|
||||
${field('status')}
|
||||
${field('qty')}
|
||||
${field('qty2')}
|
||||
${field('ageByDays')}
|
||||
${field('priority')}
|
||||
</div>
|
||||
|
||||
<!-- Product Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">產品資訊</div>
|
||||
${field('product')}
|
||||
${field('productLine')}
|
||||
${field('packageLef')}
|
||||
${field('pjType')}
|
||||
${field('pjFunction')}
|
||||
${field('bop')}
|
||||
${field('dateCode')}
|
||||
${field('produceRegion')}
|
||||
</div>
|
||||
|
||||
<!-- Process Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">製程資訊</div>
|
||||
${field('workcenterGroup')}
|
||||
${field('workcenter')}
|
||||
${field('spec')}
|
||||
${field('specSequence')}
|
||||
${field('workflow')}
|
||||
${field('equipment')}
|
||||
${field('equipmentCount')}
|
||||
${field('location')}
|
||||
</div>
|
||||
|
||||
<!-- Material Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">物料資訊</div>
|
||||
${field('waferLotId')}
|
||||
${field('waferPn')}
|
||||
${field('waferLotPrefix')}
|
||||
${field('leadframeName')}
|
||||
${field('leadframeOption')}
|
||||
${field('compoundName')}
|
||||
${field('dieConsumption')}
|
||||
${field('uts')}
|
||||
</div>
|
||||
|
||||
<!-- Hold Info (if HOLD status) -->
|
||||
${data.wipStatus === 'HOLD' || data.holdCount > 0 ? `
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">Hold 資訊</div>
|
||||
${field('holdReason')}
|
||||
${field('holdCount')}
|
||||
${field('holdEmp')}
|
||||
${field('holdDept')}
|
||||
${field('holdComment')}
|
||||
${field('releaseTime')}
|
||||
${field('releaseEmp')}
|
||||
${field('releaseComment')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- NCR Info (if exists) -->
|
||||
${data.ncrId ? `
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">NCR 資訊</div>
|
||||
${field('ncrId')}
|
||||
${field('ncrDate')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">備註資訊</div>
|
||||
${field('comment')}
|
||||
${field('commentDate')}
|
||||
${field('commentEmp')}
|
||||
${field('futureHoldComment')}
|
||||
</div>
|
||||
|
||||
<!-- Other Info -->
|
||||
<div class="lot-detail-section">
|
||||
<div class="lot-detail-section-title">其他資訊</div>
|
||||
${field('owner')}
|
||||
${field('startDate')}
|
||||
${field('tmttRemaining')}
|
||||
${field('dataUpdateDate')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 =
|
||||
'<div class="placeholder">No workcenter available</div>';
|
||||
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');
|
||||
|
||||
480
frontend/src/wip-detail/style.css
Normal file
480
frontend/src/wip-detail/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
290
frontend/src/wip-overview/App.vue
Normal file
290
frontend/src/wip-overview/App.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import {
|
||||
buildWipOverviewQueryParams,
|
||||
splitHoldByType,
|
||||
} from '../core/wip-derive.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
|
||||
import FilterPanel from './components/FilterPanel.vue';
|
||||
import MatrixTable from './components/MatrixTable.vue';
|
||||
import ParetoSection from './components/ParetoSection.vue';
|
||||
import StatusCards from './components/StatusCards.vue';
|
||||
import SummaryCards from './components/SummaryCards.vue';
|
||||
|
||||
const API_TIMEOUT = 60000;
|
||||
|
||||
const summary = ref(null);
|
||||
const matrix = ref(null);
|
||||
const hold = ref(null);
|
||||
const filters = reactive({
|
||||
workorder: '',
|
||||
lotid: '',
|
||||
package: '',
|
||||
type: '',
|
||||
});
|
||||
|
||||
const activeStatusFilter = ref(null);
|
||||
const loading = ref(true);
|
||||
const refreshing = ref(false);
|
||||
const refreshSuccess = ref(false);
|
||||
const refreshError = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
function unwrapApiResult(result, fallbackMessage) {
|
||||
if (result?.success) {
|
||||
return result.data;
|
||||
}
|
||||
if (result?.success === false) {
|
||||
throw new Error(result.error || fallbackMessage);
|
||||
}
|
||||
if (result?.data !== undefined) {
|
||||
return result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildFilters(status = null) {
|
||||
return buildWipOverviewQueryParams(filters, status);
|
||||
}
|
||||
|
||||
async function fetchSummary(signal) {
|
||||
const result = await apiGet('/api/wip/overview/summary', {
|
||||
params: buildFilters(),
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch summary');
|
||||
}
|
||||
|
||||
async function fetchMatrix(signal) {
|
||||
const result = await apiGet('/api/wip/overview/matrix', {
|
||||
params: buildFilters(activeStatusFilter.value),
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch matrix');
|
||||
}
|
||||
|
||||
async function fetchHold(signal) {
|
||||
const result = await apiGet('/api/wip/overview/hold', {
|
||||
params: buildFilters(),
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch hold data');
|
||||
}
|
||||
|
||||
const lastUpdate = computed(() => {
|
||||
return summary.value?.dataUpdateDate ? `Last Update: ${summary.value.dataUpdateDate}` : '';
|
||||
});
|
||||
|
||||
const matrixTitle = computed(() => {
|
||||
const base = 'Workcenter x Package Matrix (QTY)';
|
||||
if (!activeStatusFilter.value) {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (activeStatusFilter.value === 'quality-hold') {
|
||||
return `${base} - 品質異常 Hold Only`;
|
||||
}
|
||||
if (activeStatusFilter.value === 'non-quality-hold') {
|
||||
return `${base} - 非品質異常 Hold Only`;
|
||||
}
|
||||
return `${base} - ${activeStatusFilter.value.toUpperCase()} Only`;
|
||||
});
|
||||
|
||||
const splitHold = computed(() => splitHoldByType(hold.value));
|
||||
|
||||
const { createAbortSignal, resetAutoRefresh, triggerRefresh } = useAutoRefresh({
|
||||
onRefresh: () => loadAllData(false),
|
||||
autoStart: true,
|
||||
});
|
||||
|
||||
function showRefreshSuccess() {
|
||||
refreshSuccess.value = true;
|
||||
setTimeout(() => {
|
||||
refreshSuccess.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function loadAllData(showOverlay = true) {
|
||||
const signal = createAbortSignal('wip-overview-all');
|
||||
|
||||
if (showOverlay) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
refreshing.value = true;
|
||||
refreshError.value = false;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const [summaryData, matrixData, holdData] = await Promise.all([
|
||||
fetchSummary(signal),
|
||||
fetchMatrix(signal),
|
||||
fetchHold(signal),
|
||||
]);
|
||||
|
||||
summary.value = summaryData;
|
||||
matrix.value = matrixData;
|
||||
hold.value = holdData;
|
||||
showRefreshSuccess();
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
refreshError.value = true;
|
||||
errorMessage.value = error?.message || '載入資料失敗';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMatrixOnly() {
|
||||
const signal = createAbortSignal('wip-overview-matrix');
|
||||
refreshing.value = true;
|
||||
refreshError.value = false;
|
||||
|
||||
try {
|
||||
matrix.value = await fetchMatrix(signal);
|
||||
showRefreshSuccess();
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
refreshError.value = true;
|
||||
errorMessage.value = error?.message || '載入 Matrix 失敗';
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStatusFilter(status) {
|
||||
activeStatusFilter.value = activeStatusFilter.value === status ? null : status;
|
||||
void loadMatrixOnly();
|
||||
}
|
||||
|
||||
function updateFilters(nextFilters) {
|
||||
filters.workorder = nextFilters.workorder || '';
|
||||
filters.lotid = nextFilters.lotid || '';
|
||||
filters.package = nextFilters.package || '';
|
||||
filters.type = nextFilters.type || '';
|
||||
}
|
||||
|
||||
function applyFilters(nextFilters) {
|
||||
updateFilters(nextFilters);
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
updateFilters({ workorder: '', lotid: '', package: '', type: '' });
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function removeFilter(field) {
|
||||
filters[field] = '';
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function navigateToDetail(workcenter) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('workcenter', workcenter);
|
||||
|
||||
if (filters.workorder) {
|
||||
params.append('workorder', filters.workorder);
|
||||
}
|
||||
if (filters.lotid) {
|
||||
params.append('lotid', filters.lotid);
|
||||
}
|
||||
if (filters.package) {
|
||||
params.append('package', filters.package);
|
||||
}
|
||||
if (filters.type) {
|
||||
params.append('type', filters.type);
|
||||
}
|
||||
|
||||
window.location.href = `/wip-detail?${params.toString()}`;
|
||||
}
|
||||
|
||||
function navigateToHoldDetail(reason) {
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
window.location.href = `/hold-detail?reason=${encodeURIComponent(reason)}`;
|
||||
}
|
||||
|
||||
async function manualRefresh() {
|
||||
await triggerRefresh({ resetTimer: true, force: true });
|
||||
}
|
||||
|
||||
void loadAllData(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard wip-overview-page">
|
||||
<header class="header">
|
||||
<h1>WIP 即時概況</h1>
|
||||
<div class="header-right">
|
||||
<span class="last-update">
|
||||
<span class="refresh-indicator" :class="{ active: refreshing }"></span>
|
||||
<span class="refresh-success" :class="{ active: refreshSuccess }">✓</span>
|
||||
<span class="refresh-error" :class="{ active: refreshError }"></span>
|
||||
<span>{{ lastUpdate }}</span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-light" @click="manualRefresh">重新整理</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<FilterPanel
|
||||
:filters="filters"
|
||||
@apply="applyFilters"
|
||||
@clear="clearFilters"
|
||||
@remove="removeFilter"
|
||||
/>
|
||||
|
||||
<SummaryCards :summary="summary" />
|
||||
|
||||
<StatusCards
|
||||
:summary="summary?.byWipStatus || {}"
|
||||
:active-status="activeStatusFilter"
|
||||
@toggle="toggleStatusFilter"
|
||||
/>
|
||||
|
||||
<section class="content-grid">
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">{{ matrixTitle }}</div>
|
||||
</div>
|
||||
<div class="card-body matrix-container">
|
||||
<MatrixTable :data="matrix" @drilldown="navigateToDetail" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pareto-grid">
|
||||
<ParetoSection
|
||||
type="quality"
|
||||
title="品質異常 Hold"
|
||||
:items="splitHold.quality"
|
||||
@drilldown="navigateToHoldDetail"
|
||||
/>
|
||||
<ParetoSection
|
||||
type="non-quality"
|
||||
title="非品質異常 Hold"
|
||||
:items="splitHold.nonQuality"
|
||||
@drilldown="navigateToHoldDetail"
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
133
frontend/src/wip-overview/components/FilterPanel.vue
Normal file
133
frontend/src/wip-overview/components/FilterPanel.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
import { apiGet } from '../../core/api.js';
|
||||
import { useAutocomplete } from '../../wip-shared/composables/useAutocomplete.js';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['apply', 'clear', 'remove']);
|
||||
|
||||
const draft = reactive({
|
||||
workorder: '',
|
||||
lotid: '',
|
||||
package: '',
|
||||
type: '',
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters,
|
||||
(nextFilters) => {
|
||||
draft.workorder = nextFilters.workorder || '';
|
||||
draft.lotid = nextFilters.lotid || '';
|
||||
draft.package = nextFilters.package || '';
|
||||
draft.type = nextFilters.type || '';
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const { ensureField, handleInput, handleFocus, handleBlur, selectItem } = useAutocomplete({
|
||||
getFilters: () => ({ ...draft }),
|
||||
request: (url, options) => apiGet(url, options),
|
||||
debounceMs: 300,
|
||||
});
|
||||
|
||||
const fields = [
|
||||
{ key: 'workorder', label: 'WORKORDER', placeholder: '輸入 WORKORDER...' },
|
||||
{ key: 'lotid', label: 'LOT ID', placeholder: '輸入 LOT ID...' },
|
||||
{ key: 'package', label: 'PACKAGE', placeholder: '輸入 PACKAGE...' },
|
||||
{ key: 'type', label: 'TYPE', placeholder: '輸入 TYPE...' },
|
||||
];
|
||||
|
||||
function getFieldState(field) {
|
||||
return ensureField(field);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
emit('apply', { ...draft });
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
draft.workorder = '';
|
||||
draft.lotid = '';
|
||||
draft.package = '';
|
||||
draft.type = '';
|
||||
emit('clear');
|
||||
}
|
||||
|
||||
function removeFilter(field) {
|
||||
draft[field] = '';
|
||||
emit('remove', field);
|
||||
}
|
||||
|
||||
function onInput(field, event) {
|
||||
draft[field] = event.target.value;
|
||||
handleInput(field, draft[field]);
|
||||
}
|
||||
|
||||
function onSelect(field, value) {
|
||||
draft[field] = selectItem(field, value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="filters">
|
||||
<div v-for="field in fields" :key="field.key" class="filter-group">
|
||||
<label>{{ field.label }}</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="draft[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
autocomplete="off"
|
||||
@input="onInput(field.key, $event)"
|
||||
@focus="handleFocus(field.key)"
|
||||
@blur="handleBlur(field.key)"
|
||||
@keydown.enter.prevent="applyFilters"
|
||||
/>
|
||||
<span class="search-loading" :class="{ active: getFieldState(field.key).loading }"></span>
|
||||
<div class="autocomplete-dropdown" :class="{ active: getFieldState(field.key).open }">
|
||||
<div
|
||||
v-for="item in getFieldState(field.key).items"
|
||||
:key="item"
|
||||
class="autocomplete-item"
|
||||
@mousedown.prevent="onSelect(field.key, item)"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
<div
|
||||
v-if="!getFieldState(field.key).loading && getFieldState(field.key).open && getFieldState(field.key).items.length === 0"
|
||||
class="autocomplete-item no-results"
|
||||
>
|
||||
無符合結果
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-primary" @click="applyFilters">套用篩選</button>
|
||||
<button type="button" class="btn-secondary" @click="clearFilters">清除篩選</button>
|
||||
|
||||
<div class="active-filters">
|
||||
<span v-if="filters.workorder" class="filter-tag">
|
||||
WO: {{ filters.workorder }}
|
||||
<span class="remove" @click="removeFilter('workorder')">×</span>
|
||||
</span>
|
||||
<span v-if="filters.lotid" class="filter-tag">
|
||||
Lot: {{ filters.lotid }}
|
||||
<span class="remove" @click="removeFilter('lotid')">×</span>
|
||||
</span>
|
||||
<span v-if="filters.package" class="filter-tag">
|
||||
Pkg: {{ filters.package }}
|
||||
<span class="remove" @click="removeFilter('package')">×</span>
|
||||
</span>
|
||||
<span v-if="filters.type" class="filter-tag">
|
||||
Type: {{ filters.type }}
|
||||
<span class="remove" @click="removeFilter('type')">×</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
56
frontend/src/wip-overview/components/MatrixTable.vue
Normal file
56
frontend/src/wip-overview/components/MatrixTable.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['drilldown']);
|
||||
|
||||
const workcenters = computed(() => props.data?.workcenters || []);
|
||||
const packages = computed(() => (props.data?.packages || []).slice(0, 15));
|
||||
|
||||
function formatNumber(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
return Number(value).toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function getMatrixValue(workcenter, pkg) {
|
||||
return props.data?.matrix?.[workcenter]?.[pkg] || 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="workcenters.length === 0" class="placeholder">No data available</div>
|
||||
<table v-else class="matrix-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Workcenter</th>
|
||||
<th v-for="pkg in packages" :key="pkg">{{ pkg }}</th>
|
||||
<th class="total-col">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="workcenter in workcenters" :key="workcenter">
|
||||
<td class="clickable" @click="emit('drilldown', workcenter)">{{ workcenter }}</td>
|
||||
<td v-for="pkg in packages" :key="`${workcenter}-${pkg}`">
|
||||
{{ formatNumber(getMatrixValue(workcenter, pkg)) }}
|
||||
</td>
|
||||
<td class="total-col">{{ formatNumber(data?.workcenter_totals?.[workcenter]) }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
<td v-for="pkg in packages" :key="`total-${pkg}`">
|
||||
{{ formatNumber(data?.package_totals?.[pkg]) }}
|
||||
</td>
|
||||
<td class="total-col">{{ formatNumber(data?.grand_total) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
186
frontend/src/wip-overview/components/ParetoSection.vue
Normal file
186
frontend/src/wip-overview/components/ParetoSection.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
} from 'echarts/components';
|
||||
|
||||
import { prepareParetoData } from '../../core/wip-derive.js';
|
||||
|
||||
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['drilldown']);
|
||||
|
||||
const paretoData = computed(() => prepareParetoData(props.items));
|
||||
const hasData = computed(() => paretoData.value.items.length > 0);
|
||||
const countLabel = computed(() => `${paretoData.value.items.length} 項`);
|
||||
|
||||
const headerClass = computed(() => {
|
||||
return props.type === 'quality' ? 'quality' : 'non-quality';
|
||||
});
|
||||
|
||||
function formatNumber(value) {
|
||||
if (!value) {
|
||||
return '0';
|
||||
}
|
||||
return Number(value).toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function onReasonDrilldown(reason) {
|
||||
if (!reason || reason === '未知') {
|
||||
return;
|
||||
}
|
||||
emit('drilldown', reason);
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const barColor = props.type === 'quality' ? '#ef4444' : '#f97316';
|
||||
const lineColor = props.type === 'quality' ? '#991B1B' : '#9A3412';
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter(params) {
|
||||
const reason = params?.[0]?.name || '';
|
||||
const qty = params?.[0]?.value || 0;
|
||||
const cumPct = params?.[1]?.value || 0;
|
||||
return `<strong>${reason}</strong><br/>QTY: ${formatNumber(qty)}<br/>累計: ${cumPct}%`;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '10%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: paretoData.value.reasons,
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
interval: 0,
|
||||
fontSize: 11,
|
||||
formatter(value) {
|
||||
return value.length > 8 ? `${value.slice(0, 8)}…` : value;
|
||||
},
|
||||
},
|
||||
axisTick: { alignWithLabel: true },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'QTY',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '累計%',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'QTY',
|
||||
type: 'bar',
|
||||
barMaxWidth: 40,
|
||||
data: paretoData.value.qtys,
|
||||
itemStyle: { color: barColor },
|
||||
},
|
||||
{
|
||||
name: '累計%',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: paretoData.value.cumulative,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: lineColor, width: 2 },
|
||||
itemStyle: { color: lineColor },
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
function handleChartClick(params) {
|
||||
if (params.componentType !== 'series' || params.seriesType !== 'bar') {
|
||||
return;
|
||||
}
|
||||
const reason = paretoData.value.reasons[params.dataIndex];
|
||||
onReasonDrilldown(reason);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pareto-section">
|
||||
<div class="pareto-header" :class="headerClass">
|
||||
<div class="pareto-title">
|
||||
{{ title }}
|
||||
<span class="badge">{{ countLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pareto-body">
|
||||
<VChart
|
||||
v-if="hasData"
|
||||
class="pareto-chart"
|
||||
:option="chartOption"
|
||||
autoresize
|
||||
@click="handleChartClick"
|
||||
/>
|
||||
<div v-else class="pareto-no-data">目前無資料</div>
|
||||
|
||||
<table v-if="hasData" class="pareto-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hold Reason</th>
|
||||
<th>Lots</th>
|
||||
<th>QTY</th>
|
||||
<th>累計%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in paretoData.items" :key="`${item.reason || 'unknown'}-${index}`">
|
||||
<td>
|
||||
<a
|
||||
v-if="item.reason"
|
||||
href="#"
|
||||
class="reason-link"
|
||||
@click.prevent="onReasonDrilldown(item.reason)"
|
||||
>
|
||||
{{ item.reason }}
|
||||
</a>
|
||||
<span v-else>未知</span>
|
||||
</td>
|
||||
<td>{{ formatNumber(item.lots) }}</td>
|
||||
<td>{{ formatNumber(item.qty) }}</td>
|
||||
<td class="cumulative">{{ paretoData.cumulative[index] }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
59
frontend/src/wip-overview/components/StatusCards.vue
Normal file
59
frontend/src/wip-overview/components/StatusCards.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
activeStatus: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const cards = [
|
||||
{ key: 'run', label: 'RUN', className: 'run' },
|
||||
{ key: 'queue', label: 'QUEUE', className: 'queue' },
|
||||
{ key: 'quality-hold', label: '品質異常', className: 'quality-hold' },
|
||||
{ key: 'non-quality-hold', label: '非品質異常', className: 'non-quality-hold' },
|
||||
];
|
||||
|
||||
function resolveData(key) {
|
||||
if (key === 'quality-hold') {
|
||||
return props.summary?.qualityHold || {};
|
||||
}
|
||||
if (key === 'non-quality-hold') {
|
||||
return props.summary?.nonQualityHold || {};
|
||||
}
|
||||
return props.summary?.[key] || {};
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '-') {
|
||||
return '-';
|
||||
}
|
||||
return Number(value).toLocaleString('zh-TW');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wip-status-row" :class="{ filtering: activeStatus }">
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
class="wip-status-card"
|
||||
:class="[card.className, { active: activeStatus === card.key }]"
|
||||
@click="emit('toggle', card.key)"
|
||||
>
|
||||
<div class="status-header">
|
||||
<span class="dot"></span>
|
||||
{{ card.label }}
|
||||
</div>
|
||||
<div class="status-values">
|
||||
<span>{{ formatNumber(resolveData(card.key).lots) }}</span>
|
||||
<span>{{ formatNumber(resolveData(card.key).qtyPcs) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
57
frontend/src/wip-overview/components/SummaryCards.vue
Normal file
57
frontend/src/wip-overview/components/SummaryCards.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const lotsUpdated = ref(false);
|
||||
const qtyUpdated = ref(false);
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '-') {
|
||||
return '-';
|
||||
}
|
||||
return Number(value).toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.summary?.totalLots,
|
||||
() => {
|
||||
lotsUpdated.value = true;
|
||||
setTimeout(() => {
|
||||
lotsUpdated.value = false;
|
||||
}, 500);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.summary?.totalQtyPcs,
|
||||
() => {
|
||||
qtyUpdated.value = true;
|
||||
setTimeout(() => {
|
||||
qtyUpdated.value = false;
|
||||
}, 500);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="summary-row overview-summary-row">
|
||||
<article class="summary-card">
|
||||
<div class="summary-label">Total Lots</div>
|
||||
<div class="summary-value" :class="{ updated: lotsUpdated }">
|
||||
{{ formatNumber(summary?.totalLots) }}
|
||||
</div>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<div class="summary-label">Total QTY</div>
|
||||
<div class="summary-value" :class="{ updated: qtyUpdated }">
|
||||
{{ formatNumber(summary?.totalQtyPcs) }}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
12
frontend/src/wip-overview/index.html
Normal file
12
frontend/src/wip-overview/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WIP 即時概況</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = '<div class="autocomplete-item no-results">無符合結果</div>';
|
||||
} else {
|
||||
dropdown.innerHTML = items.map(item =>
|
||||
`<div class="autocomplete-item" onclick="selectAutocomplete('${type}', '${item}')">${item}</div>`
|
||||
).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 += `<span class="filter-tag">WO: ${state.filters.workorder} <span class="remove" onclick="removeFilter('workorder')">×</span></span>`;
|
||||
}
|
||||
if (state.filters.lotid) {
|
||||
html += `<span class="filter-tag">Lot: ${state.filters.lotid} <span class="remove" onclick="removeFilter('lotid')">×</span></span>`;
|
||||
}
|
||||
if (state.filters.package) {
|
||||
html += `<span class="filter-tag">Pkg: ${state.filters.package} <span class="remove" onclick="removeFilter('package')">×</span></span>`;
|
||||
}
|
||||
if (state.filters.type) {
|
||||
html += `<span class="filter-tag">Type: ${state.filters.type} <span class="remove" onclick="removeFilter('type')">×</span></span>`;
|
||||
}
|
||||
|
||||
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 = '<div class="placeholder">Loading...</div>';
|
||||
|
||||
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 = '<div class="placeholder">Error loading data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderMatrix(data) {
|
||||
const container = document.getElementById('matrixContainer');
|
||||
|
||||
if (!data || !data.workcenters || data.workcenters.length === 0) {
|
||||
container.innerHTML = '<div class="placeholder">No data available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit packages to top 15 for display
|
||||
const displayPackages = data.packages.slice(0, 15);
|
||||
|
||||
let html = '<table class="matrix-table"><thead><tr>';
|
||||
html += '<th>Workcenter</th>';
|
||||
displayPackages.forEach(pkg => {
|
||||
html += `<th>${pkg}</th>`;
|
||||
});
|
||||
html += '<th class="total-col">Total</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
// Data rows
|
||||
data.workcenters.forEach(wc => {
|
||||
html += '<tr>';
|
||||
html += `<td class="clickable" onclick="navigateToDetail('${wc.replace(/'/g, "\\'")}')">${wc}</td>`;
|
||||
|
||||
displayPackages.forEach(pkg => {
|
||||
const qty = data.matrix[wc]?.[pkg] || 0;
|
||||
html += `<td>${qty ? formatNumber(qty) : '-'}</td>`;
|
||||
});
|
||||
|
||||
html += `<td class="total-col">${formatNumber(data.workcenter_totals[wc] || 0)}</td>`;
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
// Total row
|
||||
html += '<tr class="total-row">';
|
||||
html += '<td>Total</td>';
|
||||
displayPackages.forEach(pkg => {
|
||||
html += `<td>${formatNumber(data.package_totals[pkg] || 0)}</td>`;
|
||||
});
|
||||
html += `<td class="total-col">${formatNumber(data.grand_total || 0)}</td>`;
|
||||
html += '</tr>';
|
||||
|
||||
html += '</tbody></table>';
|
||||
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 `<strong>${reason}</strong><br/>QTY: ${formatNumber(qty)}<br/>累計: ${cumPct}%`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: '10%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: paretoData.reasons,
|
||||
axisLabel: {
|
||||
rotate: 30,
|
||||
interval: 0,
|
||||
fontSize: 10,
|
||||
formatter: function(value) {
|
||||
return value.length > 12 ? value.slice(0, 12) + '...' : value;
|
||||
}
|
||||
},
|
||||
axisTick: { alignWithLabel: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'QTY',
|
||||
position: 'left',
|
||||
axisLabel: {
|
||||
formatter: function(val) {
|
||||
return val >= 1000 ? (val / 1000).toFixed(0) + 'k' : val;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '累計%',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'QTY',
|
||||
type: 'bar',
|
||||
data: paretoData.qtys,
|
||||
itemStyle: { color: barColor },
|
||||
emphasis: {
|
||||
itemStyle: { color: barColor, opacity: 0.8 }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '累計%',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: paretoData.cumulative,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: lineColor, width: 2 },
|
||||
itemStyle: { color: lineColor }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
|
||||
// Task 3.3: Add click event for drill-down
|
||||
chart.off('click'); // Remove existing handlers
|
||||
chart.on('click', function(params) {
|
||||
if (params.componentType === 'series' && params.seriesType === 'bar') {
|
||||
const reason = paretoData.reasons[params.dataIndex];
|
||||
if (reason && reason !== '未知') {
|
||||
window.location.href = `/hold-detail?reason=${encodeURIComponent(reason)}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Task 4.1 & 4.2: Render Pareto table with drill-down links
|
||||
function renderParetoTable(containerId, paretoData) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (!paretoData.items || paretoData.items.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="pareto-table"><thead><tr>';
|
||||
html += '<th>Hold Reason</th>';
|
||||
html += '<th>Lots</th>';
|
||||
html += '<th>QTY</th>';
|
||||
html += '<th>累計%</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
paretoData.items.forEach((item, idx) => {
|
||||
const reason = item.reason || '未知';
|
||||
const reasonLink = item.reason
|
||||
? `<a href="/hold-detail?reason=${encodeURIComponent(item.reason)}" class="reason-link">${reason}</a>`
|
||||
: reason;
|
||||
html += '<tr>';
|
||||
html += `<td>${reasonLink}</td>`;
|
||||
html += `<td>${formatNumber(item.lots)}</td>`;
|
||||
html += `<td>${formatNumber(item.qty)}</td>`;
|
||||
html += `<td class="cumulative">${paretoData.cumulative[idx]}%</td>`;
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
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');
|
||||
|
||||
499
frontend/src/wip-overview/style.css
Normal file
499
frontend/src/wip-overview/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
35
frontend/src/wip-shared/components/Pagination.vue
Normal file
35
frontend/src/wip-shared/components/Pagination.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
infoText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['prev', 'next']);
|
||||
|
||||
const canPrev = computed(() => props.page > 1);
|
||||
const canNext = computed(() => props.page < props.totalPages);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="pagination">
|
||||
<button type="button" :disabled="!canPrev" @click="emit('prev')">Prev</button>
|
||||
<span class="page-info">{{ infoText || `Page ${page} / ${totalPages}` }}</span>
|
||||
<button type="button" :disabled="!canNext" @click="emit('next')">Next</button>
|
||||
</div>
|
||||
</template>
|
||||
99
frontend/src/wip-shared/composables/useAutoRefresh.js
Normal file
99
frontend/src/wip-shared/composables/useAutoRefresh.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
132
frontend/src/wip-shared/composables/useAutocomplete.js
Normal file
132
frontend/src/wip-shared/composables/useAutocomplete.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
15
frontend/src/wip-shared/constants.js
Normal file
15
frontend/src/wip-shared/constants.js
Normal file
@@ -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);
|
||||
347
frontend/src/wip-shared/styles.css
Normal file
347
frontend/src/wip-shared/styles.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-09
|
||||
@@ -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 行),每頁完成後即可獨立測試。
|
||||
@@ -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` 頁面路由變更
|
||||
@@ -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
|
||||
@@ -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 `<page-name>.html`, `<page-name>.js`, and `<page-name>.css`
|
||||
|
||||
#### Scenario: Chunk splitting
|
||||
- **WHEN** Vite builds the project
|
||||
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
|
||||
- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk
|
||||
- **THEN** chunk splitting SHALL NOT affect existing page bundles
|
||||
|
||||
#### Scenario: Migrated page entry replacement
|
||||
- **WHEN** a vanilla JS page is migrated to Vue 3
|
||||
- **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
119
openspec/specs/hold-detail-page/spec.md
Normal file
119
openspec/specs/hold-detail-page/spec.md
Normal file
@@ -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
|
||||
@@ -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 `<meta name="csrf-token">`
|
||||
- **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`
|
||||
|
||||
124
openspec/specs/wip-detail-page/spec.md
Normal file
124
openspec/specs/wip-detail-page/spec.md
Normal file
@@ -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
|
||||
111
openspec/specs/wip-overview-page/spec.md
Normal file
111
openspec/specs/wip-overview-page/spec.md
Normal file
@@ -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
|
||||
@@ -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 (
|
||||
"<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"UTF-8\">"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
|
||||
"<title>WIP Overview Dashboard</title>"
|
||||
"<script type=\"module\" src=\"/static/dist/wip-overview.js\"></script>"
|
||||
"</head><body><div id='app'></div></body></html>",
|
||||
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 (
|
||||
"<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"UTF-8\">"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
|
||||
"<title>WIP Detail Dashboard</title>"
|
||||
"<script type=\"module\" src=\"/static/dist/wip-detail.js\"></script>"
|
||||
"</head><body><div id='app'></div></body></html>",
|
||||
200,
|
||||
)
|
||||
|
||||
@app.route('/resource')
|
||||
def resource_page():
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
Contains Flask Blueprint for Hold Detail page and API endpoints.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, redirect, url_for
|
||||
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
|
||||
@@ -12,7 +15,6 @@ from mes_dashboard.services.wip_service import (
|
||||
get_hold_detail_summary,
|
||||
get_hold_detail_distribution,
|
||||
get_hold_detail_lots,
|
||||
is_quality_hold,
|
||||
)
|
||||
|
||||
# Create Blueprint
|
||||
@@ -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 (
|
||||
"<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"UTF-8\">"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
|
||||
f"<title>Hold Detail - {safe_reason}</title>"
|
||||
"<script type=\"module\" src=\"/static/dist/hold-detail.js\"></script>"
|
||||
f"<meta name=\"hold-reason\" content=\"{safe_reason}\">"
|
||||
"</head><body><div id='app'></div></body></html>",
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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."""
|
||||
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.assertIn(b'YieldLimit', response.data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'/static/dist/hold-detail.js', response.data)
|
||||
|
||||
|
||||
class TestHoldDetailSummaryRoute(TestHoldRoutesBase):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user