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:
egg
2026-02-09 16:39:20 +08:00
parent dcbf6dcf1f
commit a2653b8139
53 changed files with 5397 additions and 6646 deletions

View File

@@ -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 DataFrame30s 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/TYPEcross-filter + 300ms debounce
- 矩陣點擊 drill-down 至 WIP Detail、Pareto 點擊 drill-down 至 Hold Detail
- 10 分鐘自動刷新 + AbortController 請求取消
- **技術架構**Vue 3 + VitePareto 圖使用 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 + ViteURL 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 元件
- OverviewPareto 圖改用 vue-echarts`<VChart autoresize>`),與 QC-GATE 一致
- Hold DetailJinja2 模板注入(`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

View File

@@ -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": {

View File

@@ -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;
}

View 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">&larr; 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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');

View 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;
}
}

View 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">&larr; 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 }">&#10003;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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');

View 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;
}
}

View 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 }">&#10003;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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');

View 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;
}
}

View 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>

View 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,
};
}

View 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,
};
}

View 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);

View 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;
}
}

View File

@@ -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'),

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-09

View File

@@ -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 OverviewApp.vue + 5 元件):**
- `FilterPanel.vue`4 個 autocomplete 輸入 + filter tags
- `SummaryCards.vue`2 個 KPI 卡片
- `StatusCards.vue`4 個可點擊狀態卡片
- `MatrixTable.vue`Workcenter × Package 交叉表
- `ParetoSection.vue`Pareto 圖 + 明細表(品質/非品質各一個實例)
**WIP DetailApp.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 DetailApp.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 行),每頁完成後即可獨立測試。

View File

@@ -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` 頁面路由變更

View File

@@ -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

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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`

View 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

View 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

View File

@@ -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():

View File

@@ -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

View File

@@ -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):

View File

@@ -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):