feat(hold-overview): restructure WIP/Hold pages — migrate Pareto, unify filters and lot table
- Move Hold Pareto charts from WIP Overview to Hold Overview with conditional visibility by holdType - WIP Overview hold cards now navigate to Hold Overview instead of filtering matrix - Add 6-field FilterPanel to Hold Overview (reuse WIP Overview's FilterPanel) - Default holdType to "all" when entering Hold Overview directly - Unify lot table to 13 columns (shared HoldLotTable component) across Hold Overview and Hold Detail - Hold Detail back button now returns to Hold Overview instead of WIP Overview - Backend: thread WIP filter params through hold-overview summary/matrix/lots APIs - Fix nativeModuleRegistry CSS imports for hold-overview and query-tool - Migrate ParetoSection.vue and pareto CSS to wip-shared for cross-page reuse Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,10 @@ import { apiGet } from '../core/api.js';
|
||||
import { navigateToRuntimeRoute, replaceRuntimeHistory, toRuntimeRoute } from '../core/shell-navigation.js';
|
||||
import { NON_QUALITY_HOLD_REASON_SET } from '../wip-shared/constants.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
import HoldLotTable from '../wip-shared/components/HoldLotTable.vue';
|
||||
|
||||
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 API_TIMEOUT = 60000;
|
||||
@@ -104,7 +104,7 @@ const holdType = computed(() => {
|
||||
});
|
||||
|
||||
const holdTypeLabel = computed(() => (holdType.value === 'quality' ? '品質異常' : '非品質異常'));
|
||||
const backToOverviewHref = toRuntimeRoute('/wip-overview');
|
||||
const backToOverviewHref = toRuntimeRoute('/hold-overview');
|
||||
|
||||
const headerStyle = computed(() => ({
|
||||
'--header-gradient': holdType.value === 'quality'
|
||||
@@ -292,7 +292,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
if (!reason.value) {
|
||||
navigateToRuntimeRoute('/wip-overview', { replace: true });
|
||||
navigateToRuntimeRoute('/hold-overview', { replace: true });
|
||||
return;
|
||||
}
|
||||
updateUrlState();
|
||||
@@ -304,7 +304,7 @@ onMounted(() => {
|
||||
<div class="dashboard hold-detail-page">
|
||||
<header class="header" :style="headerStyle">
|
||||
<div class="header-left">
|
||||
<a :href="backToOverviewHref" class="btn btn-back">← WIP Overview</a>
|
||||
<a :href="backToOverviewHref" class="btn btn-back">← Hold Overview</a>
|
||||
<h1>Hold Detail: {{ reason }}</h1>
|
||||
<span class="hold-type-badge">{{ holdTypeLabel }}</span>
|
||||
</div>
|
||||
@@ -343,13 +343,14 @@ onMounted(() => {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<LotTable
|
||||
<HoldLotTable
|
||||
:lots="lots"
|
||||
:pagination="pagination"
|
||||
:loading="lotsLoading"
|
||||
:error-message="lotsError"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
:filter-text="filterText"
|
||||
title="Lot Details"
|
||||
@clear-filters="clearFilters"
|
||||
@prev-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
|
||||
@@ -225,9 +225,8 @@
|
||||
}
|
||||
|
||||
.lot-table th:nth-child(3),
|
||||
.lot-table th:nth-child(7),
|
||||
.lot-table td:nth-child(3),
|
||||
.lot-table td:nth-child(7) {
|
||||
.lot-table td:nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
import { navigateToRuntimeRoute, replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
import { buildWipOverviewQueryParams, splitHoldByType } from '../core/wip-derive.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
import HoldLotTable from '../wip-shared/components/HoldLotTable.vue';
|
||||
import ParetoSection from '../wip-shared/components/ParetoSection.vue';
|
||||
|
||||
import FilterPanel from '../wip-overview/components/FilterPanel.vue';
|
||||
import SummaryCards from '../hold-detail/components/SummaryCards.vue';
|
||||
import FilterBar from './components/FilterBar.vue';
|
||||
import FilterIndicator from './components/FilterIndicator.vue';
|
||||
import HoldMatrix from './components/HoldMatrix.vue';
|
||||
import LotTable from './components/LotTable.vue';
|
||||
|
||||
const API_TIMEOUT = 60000;
|
||||
const DEFAULT_PER_PAGE = 50;
|
||||
const FILTER_OPTION_DEBOUNCE_MS = 120;
|
||||
|
||||
const summary = ref(null);
|
||||
const matrix = ref(null);
|
||||
const hold = ref(null);
|
||||
const lots = ref([]);
|
||||
|
||||
const filterBar = reactive({
|
||||
holdType: 'quality',
|
||||
holdType: 'all',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const filterOptions = ref({
|
||||
workorders: [],
|
||||
lotids: [],
|
||||
packages: [],
|
||||
types: [],
|
||||
firstnames: [],
|
||||
waferdescs: [],
|
||||
});
|
||||
|
||||
const filters = reactive({
|
||||
workorder: [],
|
||||
lotid: [],
|
||||
package: [],
|
||||
type: [],
|
||||
firstname: [],
|
||||
waferdesc: [],
|
||||
});
|
||||
|
||||
const matrixFilter = ref(null);
|
||||
|
||||
const pagination = ref({
|
||||
@@ -42,6 +65,8 @@ const errorMessage = ref('');
|
||||
const lotsError = ref('');
|
||||
|
||||
let activeRequestId = 0;
|
||||
let filterOptionsDebounceTimer = null;
|
||||
let filterOptionsRequestToken = 0;
|
||||
|
||||
const holdTypeLabel = computed(() => {
|
||||
if (filterBar.holdType === 'non-quality') {
|
||||
@@ -53,7 +78,8 @@ const holdTypeLabel = computed(() => {
|
||||
return '品質異常';
|
||||
});
|
||||
|
||||
const hasCascadeFilters = computed(() => Boolean(matrixFilter.value));
|
||||
const showQualityPareto = computed(() => filterBar.holdType !== 'non-quality');
|
||||
const showNonQualityPareto = computed(() => filterBar.holdType !== 'quality');
|
||||
|
||||
const lotFilterText = computed(() => {
|
||||
const parts = [];
|
||||
@@ -98,6 +124,19 @@ const reasonOptions = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const splitHold = computed(() => {
|
||||
const base = splitHoldByType(hold.value);
|
||||
const activeReason = String(filterBar.reason || '').trim();
|
||||
if (!activeReason) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
quality: base.quality.filter((item) => String(item?.reason || '').trim() === activeReason),
|
||||
nonQuality: base.nonQuality.filter((item) => String(item?.reason || '').trim() === activeReason),
|
||||
};
|
||||
});
|
||||
|
||||
function nextRequestId() {
|
||||
activeRequestId += 1;
|
||||
return activeRequestId;
|
||||
@@ -124,41 +163,54 @@ function getUrlParam(name) {
|
||||
return new URLSearchParams(window.location.search).get(name)?.trim() || '';
|
||||
}
|
||||
|
||||
function parseCsvParam(name) {
|
||||
const raw = getUrlParam(name);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeArrayValues(values) {
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(values)) {
|
||||
return values.map((value) => String(value).trim()).filter(Boolean);
|
||||
}
|
||||
return String(values)
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function serializeFilterValue(values) {
|
||||
return normalizeArrayValues(values).join(',');
|
||||
}
|
||||
|
||||
function normalizeHoldType(value) {
|
||||
const holdType = String(value || '').trim();
|
||||
if (holdType === 'quality' || holdType === 'non-quality' || holdType === 'all') {
|
||||
return holdType;
|
||||
}
|
||||
return 'quality';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function updateUrlState() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filterBar.holdType) {
|
||||
params.set('hold_type', filterBar.holdType);
|
||||
}
|
||||
if (filterBar.reason) {
|
||||
params.set('reason', filterBar.reason);
|
||||
}
|
||||
if (matrixFilter.value?.workcenter) {
|
||||
params.set('workcenter', matrixFilter.value.workcenter);
|
||||
}
|
||||
if (matrixFilter.value?.package) {
|
||||
params.set('package', matrixFilter.value.package);
|
||||
}
|
||||
if (page.value > 1) {
|
||||
params.set('page', String(page.value));
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
const nextUrl = query ? `/hold-overview?${query}` : '/hold-overview';
|
||||
replaceRuntimeHistory(nextUrl);
|
||||
function updateFilters(nextFilters) {
|
||||
filters.workorder = normalizeArrayValues(nextFilters.workorder);
|
||||
filters.lotid = normalizeArrayValues(nextFilters.lotid);
|
||||
filters.package = normalizeArrayValues(nextFilters.package);
|
||||
filters.type = normalizeArrayValues(nextFilters.type);
|
||||
filters.firstname = normalizeArrayValues(nextFilters.firstname);
|
||||
filters.waferdesc = normalizeArrayValues(nextFilters.waferdesc);
|
||||
}
|
||||
|
||||
function buildFilterBarParams() {
|
||||
const params = {
|
||||
hold_type: filterBar.holdType || 'quality',
|
||||
hold_type: filterBar.holdType || 'all',
|
||||
};
|
||||
if (filterBar.reason) {
|
||||
params.reason = filterBar.reason;
|
||||
@@ -177,18 +229,38 @@ function buildMatrixFilterParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildLotsParams() {
|
||||
function buildAllFilterParams() {
|
||||
return {
|
||||
...buildFilterBarParams(),
|
||||
...buildWipOverviewQueryParams(filters),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLotsParams() {
|
||||
return {
|
||||
...buildAllFilterParams(),
|
||||
...buildMatrixFilterParams(),
|
||||
page: page.value,
|
||||
per_page: Number(pagination.value?.perPage || DEFAULT_PER_PAGE),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFilterOptionsParams(sourceFilters = filters) {
|
||||
const params = {
|
||||
...buildWipOverviewQueryParams(sourceFilters),
|
||||
status: 'HOLD',
|
||||
};
|
||||
|
||||
if (filterBar.holdType && filterBar.holdType !== 'all') {
|
||||
params.hold_type = filterBar.holdType;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
async function fetchSummary(signal) {
|
||||
const result = await apiGet('/api/hold-overview/summary', {
|
||||
params: buildFilterBarParams(),
|
||||
params: buildAllFilterParams(),
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
@@ -197,13 +269,22 @@ async function fetchSummary(signal) {
|
||||
|
||||
async function fetchMatrix(signal) {
|
||||
const result = await apiGet('/api/hold-overview/matrix', {
|
||||
params: buildFilterBarParams(),
|
||||
params: buildAllFilterParams(),
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch hold matrix');
|
||||
}
|
||||
|
||||
async function fetchHold(signal) {
|
||||
const result = await apiGet('/api/wip/overview/hold', {
|
||||
params: buildAllFilterParams(),
|
||||
timeout: API_TIMEOUT,
|
||||
signal,
|
||||
});
|
||||
return unwrapApiResult(result, 'Failed to fetch hold data');
|
||||
}
|
||||
|
||||
async function fetchLots(signal) {
|
||||
const result = await apiGet('/api/hold-overview/lots', {
|
||||
params: buildLotsParams(),
|
||||
@@ -213,6 +294,50 @@ async function fetchLots(signal) {
|
||||
return unwrapApiResult(result, 'Failed to fetch hold lots');
|
||||
}
|
||||
|
||||
async function loadFilterOptions(sourceFilters = filters) {
|
||||
const requestToken = ++filterOptionsRequestToken;
|
||||
|
||||
try {
|
||||
const result = await apiGet('/api/wip/meta/filter-options', {
|
||||
params: buildFilterOptionsParams(sourceFilters),
|
||||
timeout: API_TIMEOUT,
|
||||
silent: true,
|
||||
});
|
||||
const data = unwrapApiResult(result, '載入篩選選項失敗');
|
||||
|
||||
if (requestToken !== filterOptionsRequestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
filterOptions.value = {
|
||||
workorders: Array.isArray(data?.workorders) ? data.workorders : [],
|
||||
lotids: Array.isArray(data?.lotids) ? data.lotids : [],
|
||||
packages: Array.isArray(data?.packages) ? data.packages : [],
|
||||
types: Array.isArray(data?.types) ? data.types : [],
|
||||
firstnames: Array.isArray(data?.firstnames) ? data.firstnames : [],
|
||||
waferdescs: Array.isArray(data?.waferdescs) ? data.waferdescs : [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
console.warn('載入 WIP 篩選選項失敗:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFilterOptionsReload(nextDraftFilters) {
|
||||
if (filterOptionsDebounceTimer) {
|
||||
clearTimeout(filterOptionsDebounceTimer);
|
||||
}
|
||||
|
||||
filterOptionsDebounceTimer = setTimeout(() => {
|
||||
void loadFilterOptions(nextDraftFilters);
|
||||
}, FILTER_OPTION_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function onFilterDraftChange(nextDraftFilters) {
|
||||
scheduleFilterOptionsReload(nextDraftFilters);
|
||||
}
|
||||
|
||||
function updateLotsState(payload) {
|
||||
lots.value = Array.isArray(payload?.lots) ? payload.lots : [];
|
||||
pagination.value = {
|
||||
@@ -231,6 +356,56 @@ function showRefreshSuccess() {
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function updateUrlState() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filterBar.holdType) {
|
||||
params.set('hold_type', filterBar.holdType);
|
||||
}
|
||||
if (filterBar.reason) {
|
||||
params.set('reason', filterBar.reason);
|
||||
}
|
||||
if (matrixFilter.value?.workcenter) {
|
||||
params.set('workcenter', matrixFilter.value.workcenter);
|
||||
}
|
||||
if (matrixFilter.value?.package) {
|
||||
params.set('matrix_package', matrixFilter.value.package);
|
||||
}
|
||||
|
||||
const workorder = serializeFilterValue(filters.workorder);
|
||||
const lotid = serializeFilterValue(filters.lotid);
|
||||
const pkg = serializeFilterValue(filters.package);
|
||||
const type = serializeFilterValue(filters.type);
|
||||
const firstname = serializeFilterValue(filters.firstname);
|
||||
const waferdesc = serializeFilterValue(filters.waferdesc);
|
||||
|
||||
if (workorder) {
|
||||
params.set('workorder', workorder);
|
||||
}
|
||||
if (lotid) {
|
||||
params.set('lotid', lotid);
|
||||
}
|
||||
if (pkg) {
|
||||
params.set('package', pkg);
|
||||
}
|
||||
if (type) {
|
||||
params.set('type', type);
|
||||
}
|
||||
if (firstname) {
|
||||
params.set('firstname', firstname);
|
||||
}
|
||||
if (waferdesc) {
|
||||
params.set('waferdesc', waferdesc);
|
||||
}
|
||||
if (page.value > 1) {
|
||||
params.set('page', String(page.value));
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
const nextUrl = query ? `/hold-overview?${query}` : '/hold-overview';
|
||||
replaceRuntimeHistory(nextUrl);
|
||||
}
|
||||
|
||||
const { createAbortSignal, clearAbortController, triggerRefresh } = useAutoRefresh({
|
||||
onRefresh: () => loadAllData(false),
|
||||
autoStart: true,
|
||||
@@ -251,9 +426,10 @@ async function loadAllData(showOverlay = true) {
|
||||
lotsError.value = '';
|
||||
|
||||
try {
|
||||
const [summaryData, matrixData, lotsData] = await Promise.all([
|
||||
const [summaryData, matrixData, holdData, lotsData] = await Promise.all([
|
||||
fetchSummary(signal),
|
||||
fetchMatrix(signal),
|
||||
fetchHold(signal),
|
||||
fetchLots(signal),
|
||||
]);
|
||||
if (isStaleRequest(requestId)) {
|
||||
@@ -262,6 +438,7 @@ async function loadAllData(showOverlay = true) {
|
||||
|
||||
summary.value = summaryData;
|
||||
matrix.value = matrixData;
|
||||
hold.value = holdData;
|
||||
updateLotsState(lotsData);
|
||||
showRefreshSuccess();
|
||||
} catch (error) {
|
||||
@@ -317,9 +494,17 @@ async function loadLots() {
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToHoldDetail(reason) {
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
navigateToRuntimeRoute(`/hold-detail?reason=${encodeURIComponent(reason)}`);
|
||||
}
|
||||
|
||||
function handleFilterChange(next) {
|
||||
const nextHoldType = next?.holdType || 'quality';
|
||||
const nextReason = next?.reason || '';
|
||||
const nextHoldType = normalizeHoldType(next?.holdType || 'all');
|
||||
const nextReason = String(next?.reason || '').trim();
|
||||
|
||||
if (filterBar.holdType === nextHoldType && filterBar.reason === nextReason) {
|
||||
return;
|
||||
}
|
||||
@@ -329,6 +514,7 @@ function handleFilterChange(next) {
|
||||
matrixFilter.value = null;
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadFilterOptions(filters);
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
@@ -349,6 +535,31 @@ function clearMatrixFilter() {
|
||||
void loadLots();
|
||||
}
|
||||
|
||||
function applyFilters(nextFilters) {
|
||||
updateFilters(nextFilters);
|
||||
matrixFilter.value = null;
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadFilterOptions(filters);
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
updateFilters({
|
||||
workorder: [],
|
||||
lotid: [],
|
||||
package: [],
|
||||
type: [],
|
||||
firstname: [],
|
||||
waferdesc: [],
|
||||
});
|
||||
matrixFilter.value = null;
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadFilterOptions(filters);
|
||||
void loadAllData(false);
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value <= 1) {
|
||||
return;
|
||||
@@ -371,23 +582,50 @@ async function manualRefresh() {
|
||||
await triggerRefresh({ resetTimer: true, force: true });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
filterBar.holdType = normalizeHoldType(getUrlParam('hold_type'));
|
||||
async function initializePage() {
|
||||
filterBar.holdType = normalizeHoldType(getUrlParam('hold_type') || 'all');
|
||||
filterBar.reason = getUrlParam('reason');
|
||||
|
||||
updateFilters({
|
||||
workorder: parseCsvParam('workorder'),
|
||||
lotid: parseCsvParam('lotid'),
|
||||
package: parseCsvParam('package'),
|
||||
type: parseCsvParam('type'),
|
||||
firstname: parseCsvParam('firstname'),
|
||||
waferdesc: parseCsvParam('waferdesc'),
|
||||
});
|
||||
|
||||
const workcenter = getUrlParam('workcenter');
|
||||
const pkg = getUrlParam('package');
|
||||
if (workcenter || pkg) {
|
||||
const matrixPkg = getUrlParam('matrix_package');
|
||||
if (workcenter || matrixPkg) {
|
||||
matrixFilter.value = {
|
||||
workcenter: workcenter || null,
|
||||
package: pkg || null,
|
||||
package: matrixPkg || null,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedPage = Number.parseInt(getUrlParam('page'), 10);
|
||||
if (Number.isFinite(parsedPage) && parsedPage > 0) {
|
||||
page.value = parsedPage;
|
||||
}
|
||||
|
||||
updateUrlState();
|
||||
void loadAllData(true);
|
||||
|
||||
await Promise.all([
|
||||
loadFilterOptions(filters),
|
||||
loadAllData(true),
|
||||
]);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void initializePage();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (filterOptionsDebounceTimer) {
|
||||
clearTimeout(filterOptionsDebounceTimer);
|
||||
filterOptionsDebounceTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -395,7 +633,7 @@ onMounted(() => {
|
||||
<div class="dashboard hold-overview-page">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1>Hold Lot Overview</h1>
|
||||
<h1>Hold 即時概況</h1>
|
||||
<span class="hold-type-badge">{{ holdTypeLabel }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@@ -411,6 +649,15 @@ onMounted(() => {
|
||||
|
||||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<FilterPanel
|
||||
:filters="filters"
|
||||
:options="filterOptions"
|
||||
:loading="refreshing"
|
||||
@apply="applyFilters"
|
||||
@clear="clearAllFilters"
|
||||
@draft-change="onFilterDraftChange"
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
:hold-type="filterBar.holdType"
|
||||
:reason="filterBar.reason"
|
||||
@@ -421,33 +668,52 @@ onMounted(() => {
|
||||
|
||||
<SummaryCards :summary="summary" />
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Workcenter x Package Matrix (QTY)</div>
|
||||
</div>
|
||||
<div class="card-body matrix-container">
|
||||
<HoldMatrix :data="matrix" :active-filter="matrixFilter" @select="handleMatrixSelect" />
|
||||
</div>
|
||||
<section class="content-grid">
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Workcenter x Package Matrix (QTY)</div>
|
||||
</div>
|
||||
<div class="card-body matrix-container">
|
||||
<HoldMatrix :data="matrix" :active-filter="matrixFilter" @select="handleMatrixSelect" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pareto-grid">
|
||||
<ParetoSection
|
||||
v-if="showQualityPareto"
|
||||
type="quality"
|
||||
title="品質異常 Hold"
|
||||
:items="splitHold.quality"
|
||||
@drilldown="navigateToHoldDetail"
|
||||
/>
|
||||
<ParetoSection
|
||||
v-if="showNonQualityPareto"
|
||||
type="non-quality"
|
||||
title="非品質異常 Hold"
|
||||
:items="splitHold.nonQuality"
|
||||
@drilldown="navigateToHoldDetail"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<FilterIndicator
|
||||
:matrix-filter="matrixFilter"
|
||||
:show-clear-all="true"
|
||||
@clear-matrix="clearMatrixFilter"
|
||||
@clear-all="clearMatrixFilter"
|
||||
/>
|
||||
|
||||
<HoldLotTable
|
||||
:lots="lots"
|
||||
:pagination="pagination"
|
||||
:loading="lotsLoading"
|
||||
:error-message="lotsError"
|
||||
:has-active-filters="hasLotFilterText"
|
||||
:filter-text="lotFilterText"
|
||||
@clear-filters="clearMatrixFilter"
|
||||
@prev-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<FilterIndicator
|
||||
:matrix-filter="matrixFilter"
|
||||
:show-clear-all="true"
|
||||
@clear-matrix="clearMatrixFilter"
|
||||
@clear-all="clearMatrixFilter"
|
||||
/>
|
||||
|
||||
<LotTable
|
||||
:lots="lots"
|
||||
:pagination="pagination"
|
||||
:loading="lotsLoading"
|
||||
:error-message="lotsError"
|
||||
:has-active-filters="hasLotFilterText"
|
||||
:filter-text="lotFilterText"
|
||||
@clear-filters="clearMatrixFilter"
|
||||
@prev-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="initialLoading" class="loading-overlay">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
holdType: {
|
||||
type: String,
|
||||
default: 'quality',
|
||||
default: 'all',
|
||||
},
|
||||
reason: {
|
||||
type: String,
|
||||
@@ -28,7 +28,7 @@ const HOLD_TYPE_OPTIONS = Object.freeze([
|
||||
{ value: 'all', label: '全部' },
|
||||
]);
|
||||
|
||||
const holdTypeModel = computed(() => props.holdType || 'quality');
|
||||
const holdTypeModel = computed(() => props.holdType || 'all');
|
||||
|
||||
const reasonModel = computed({
|
||||
get() {
|
||||
@@ -36,7 +36,7 @@ const reasonModel = computed({
|
||||
},
|
||||
set(nextValue) {
|
||||
emit('change', {
|
||||
holdType: props.holdType || 'quality',
|
||||
holdType: props.holdType || 'all',
|
||||
reason: nextValue || '',
|
||||
});
|
||||
},
|
||||
@@ -60,7 +60,7 @@ function selectHoldType(nextValue) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
const normalized = nextValue || 'quality';
|
||||
const normalized = nextValue || 'all';
|
||||
if (normalized === holdTypeModel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import '../resource-shared/styles.css';
|
||||
import '../wip-shared/styles.css';
|
||||
import './style.css';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '../wip-shared/pareto-styles.css';
|
||||
|
||||
.hold-overview-page .header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -21,6 +23,44 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
background: var(--card-bg);
|
||||
padding: 16px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(220px, 1fr));
|
||||
gap: 14px 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.filters .filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filters .filter-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filters-actions .btn-primary,
|
||||
.filters-actions .btn-secondary {
|
||||
min-width: 108px;
|
||||
}
|
||||
|
||||
.hold-summary-row {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
@@ -67,7 +107,7 @@
|
||||
}
|
||||
|
||||
.hold-overview-hold-type-group {
|
||||
flex: 1 1 520px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hold-overview-reason-group {
|
||||
@@ -84,7 +124,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 420px;
|
||||
min-width: 320px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
@@ -218,6 +258,12 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cascade-filter-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -356,9 +402,8 @@
|
||||
}
|
||||
|
||||
.lot-table th:nth-child(3),
|
||||
.lot-table th:nth-child(7),
|
||||
.lot-table td:nth-child(3),
|
||||
.lot-table td:nth-child(7) {
|
||||
.lot-table td:nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -377,6 +422,10 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -398,6 +447,14 @@
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.hold-summary-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
||||
),
|
||||
'/hold-overview': createNativeLoader(
|
||||
() => import('../hold-overview/App.vue'),
|
||||
[() => import('../wip-shared/styles.css'), () => import('../hold-overview/style.css')],
|
||||
[() => import('../resource-shared/styles.css'), () => import('../wip-shared/styles.css'), () => import('../hold-overview/style.css')],
|
||||
),
|
||||
'/hold-detail': createNativeLoader(
|
||||
() => import('../hold-detail/App.vue'),
|
||||
@@ -60,7 +60,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
||||
),
|
||||
'/query-tool': createNativeLoader(
|
||||
() => import('../query-tool/App.vue'),
|
||||
[() => import('../query-tool/style.css')],
|
||||
[() => import('../wip-shared/styles.css'), () => import('../styles/tailwind.css'), () => import('../query-tool/style.css')],
|
||||
),
|
||||
'/tables': createNativeLoader(
|
||||
() => import('../tables/App.vue'),
|
||||
|
||||
@@ -3,15 +3,11 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import { navigateToRuntimeRoute, replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
import {
|
||||
buildWipOverviewQueryParams,
|
||||
splitHoldByType,
|
||||
} from '../core/wip-derive.js';
|
||||
import { buildWipOverviewQueryParams } from '../core/wip-derive.js';
|
||||
import { useAutoRefresh } from '../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';
|
||||
|
||||
@@ -20,7 +16,6 @@ const FILTER_OPTION_DEBOUNCE_MS = 120;
|
||||
|
||||
const summary = ref(null);
|
||||
const matrix = ref(null);
|
||||
const hold = ref(null);
|
||||
const filterOptions = ref({
|
||||
workorders: [],
|
||||
lotids: [],
|
||||
@@ -115,15 +110,6 @@ async function fetchMatrix(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');
|
||||
}
|
||||
|
||||
async function loadFilterOptions(sourceFilters = filters) {
|
||||
const requestToken = ++filterOptionsRequestToken;
|
||||
|
||||
@@ -188,8 +174,6 @@ const matrixTitle = computed(() => {
|
||||
return `${base} - ${activeStatusFilter.value.toUpperCase()} Only`;
|
||||
});
|
||||
|
||||
const splitHold = computed(() => splitHoldByType(hold.value));
|
||||
|
||||
const { createAbortSignal, triggerRefresh } = useAutoRefresh({
|
||||
onRefresh: () => loadAllData(false),
|
||||
autoStart: true,
|
||||
@@ -214,15 +198,13 @@ async function loadAllData(showOverlay = true) {
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const [summaryData, matrixData, holdData] = await Promise.all([
|
||||
const [summaryData, matrixData] = 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') {
|
||||
@@ -256,6 +238,15 @@ async function loadMatrixOnly() {
|
||||
}
|
||||
|
||||
function toggleStatusFilter(status) {
|
||||
if (status === 'quality-hold') {
|
||||
navigateToRuntimeRoute('/hold-overview?hold_type=quality');
|
||||
return;
|
||||
}
|
||||
if (status === 'non-quality-hold') {
|
||||
navigateToRuntimeRoute('/hold-overview?hold_type=non-quality');
|
||||
return;
|
||||
}
|
||||
|
||||
activeStatusFilter.value = activeStatusFilter.value === status ? null : status;
|
||||
updateUrlState();
|
||||
void loadMatrixOnly();
|
||||
@@ -365,13 +356,6 @@ function navigateToDetail(workcenter) {
|
||||
navigateToRuntimeRoute(`/wip-detail?${params.toString()}`);
|
||||
}
|
||||
|
||||
function navigateToHoldDetail(reason) {
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
navigateToRuntimeRoute(`/hold-detail?reason=${encodeURIComponent(reason)}`);
|
||||
}
|
||||
|
||||
async function manualRefresh() {
|
||||
await triggerRefresh({ resetTimer: true, force: true });
|
||||
}
|
||||
@@ -449,20 +433,6 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import '../wip-shared/styles.css';
|
||||
@import '../resource-shared/styles.css';
|
||||
@import '../wip-shared/pareto-styles.css';
|
||||
|
||||
.wip-overview-page .header h1 {
|
||||
font-size: 24px;
|
||||
@@ -274,126 +275,6 @@
|
||||
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 {
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
|
||||
142
frontend/src/wip-shared/components/HoldLotTable.vue
Normal file
142
frontend/src/wip-shared/components/HoldLotTable.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Pagination from '../../shared-ui/components/PaginationControl.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: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Hold Lot Details',
|
||||
},
|
||||
});
|
||||
|
||||
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">{{ title }}</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>Product</th>
|
||||
<th>Package</th>
|
||||
<th>Workcenter</th>
|
||||
<th>Hold Reason</th>
|
||||
<th>Spec</th>
|
||||
<th>Age</th>
|
||||
<th>Hold By</th>
|
||||
<th>Dept</th>
|
||||
<th>Hold Comment</th>
|
||||
<th>Future Hold Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="13" class="placeholder">Loading...</td>
|
||||
</tr>
|
||||
<tr v-else-if="errorMessage">
|
||||
<td colspan="13" class="placeholder">{{ errorMessage }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="lots.length === 0">
|
||||
<td colspan="13" 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.product || '-' }}</td>
|
||||
<td>{{ lot.package || '-' }}</td>
|
||||
<td>{{ lot.workcenter || '-' }}</td>
|
||||
<td>{{ lot.holdReason || '-' }}</td>
|
||||
<td>{{ lot.spec || '-' }}</td>
|
||||
<td>{{ formatAge(lot.age) }}</td>
|
||||
<td>{{ lot.holdBy || '-' }}</td>
|
||||
<td>{{ lot.dept || '-' }}</td>
|
||||
<td>{{ lot.holdComment || '-' }}</td>
|
||||
<td>{{ lot.futureHoldComment || '-' }}</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>
|
||||
119
frontend/src/wip-shared/pareto-styles.css
Normal file
119
frontend/src/wip-shared/pareto-styles.css
Normal file
@@ -0,0 +1,119 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-03
|
||||
@@ -0,0 +1,72 @@
|
||||
## Context
|
||||
|
||||
WIP 即時概況和 Hold 即時概況共用部分 Hold 資料呈現邏輯,但目前職責劃分不清:Hold 柏拉圖放在 WIP 頁面、Hold 頁面篩選能力薄弱、Lot 明細欄位不一致。兩頁的後端 service 層(`wip_service.py`)已共用 `_select_with_snapshot_indexes()` 作為核心查詢引擎,支援 workorder/lotid/pj_type/firstname/waferdesc 篩選,但 Hold Overview 的 route 層和 service function 簽名尚未穿透這些參數。
|
||||
|
||||
**現有架構:**
|
||||
- `ParetoSection.vue` 位於 `wip-overview/components/`,僅 WIP 使用
|
||||
- Hold Overview 的 `FilterBar` 僅有 holdType + reason 兩個篩選欄位
|
||||
- Hold Overview 和 Hold Detail 各有獨立的 `LotTable.vue`,欄位不同(前者有 Hold Reason 無 Spec,後者相反)
|
||||
- Hold Detail 返回按鈕指向 WIP Overview
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Hold 相關視覺化(柏拉圖)集中到 Hold 即時概況
|
||||
- WIP 即時概況的 Hold 卡片改為跳轉入口(而非本地篩選)
|
||||
- Hold 即時概況具備與 WIP 相同的 6 欄位篩選能力
|
||||
- 統一 Lot 明細表格為 13 欄(含 Hold Reason + Spec)
|
||||
- 修正 Hold 即時概況版面窄的問題
|
||||
- Hold Detail 導航回到 Hold Overview(而非 WIP Overview)
|
||||
|
||||
**Non-Goals:**
|
||||
- 不變更 Hold 柏拉圖的資料來源 API(繼續使用 `/api/wip/overview/hold`)
|
||||
- 不變更 Hold Detail 的內部功能(AgeDistribution、DistributionTable 等)
|
||||
- 不重構後端 `wip_service.py` 的核心查詢邏輯
|
||||
- 不新增 API endpoint(只擴充現有 endpoint 的參數)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. 柏拉圖資料來源:沿用 `/api/wip/overview/hold`
|
||||
|
||||
Hold 即時概況的柏拉圖直接呼叫現有 `/api/wip/overview/hold` API,而非新增 Hold Overview 專用 endpoint。
|
||||
|
||||
**理由:** 該 API 回傳 `{items: [{reason, lots, qty, holdType}]}`,正好是 `ParetoSection` + `splitHoldByType()` 所需格式。已有的 treemap API 回傳格式不同(workcenter×reason 分組),不適合柏拉圖。
|
||||
|
||||
### D2. ParetoSection 搬遷至 `wip-shared/components/`
|
||||
|
||||
將 `ParetoSection.vue` 從 `wip-overview/components/` 移至 `wip-shared/components/`,柏拉圖 CSS 抽取為 `wip-shared/pareto-styles.css`。
|
||||
|
||||
**理由:** `wip-shared/` 是既有的共用目錄(已有 `styles.css`),符合專案的組件共用慣例。
|
||||
|
||||
### D3. 統一 LotTable 為 `wip-shared/components/HoldLotTable.vue`
|
||||
|
||||
以 Hold Overview 的 `LotTable.vue` 為基礎,新增 Spec 欄位,形成 13 欄統一表格。兩頁均改用此共用組件。
|
||||
|
||||
**理由:** 後端 `get_hold_detail_lots` 已同時回傳 `holdReason` 和 `spec`(wip_service.py:3079-3080),純前端調整。共用組件避免欄位不同步。
|
||||
|
||||
### D4. FilterPanel 直接 import WIP Overview 的組件
|
||||
|
||||
Hold Overview 的 FilterPanel 直接 `import FilterPanel from '../wip-overview/components/FilterPanel.vue'`,不複製組件。
|
||||
|
||||
**理由:** FilterPanel 的 props interface(filters/options/loading + apply/clear/draft-change events)是穩定的,且內部已正確處理 MultiSelect 相對路徑。避免組件重複。
|
||||
|
||||
### D5. 柏拉圖根據 holdType 條件顯示
|
||||
|
||||
- holdType = 'all'(預設)→ 顯示品質異常 + 非品質異常兩張柏拉圖
|
||||
- holdType = 'quality' → 僅顯示品質異常柏拉圖
|
||||
- holdType = 'non-quality' → 僅顯示非品質異常柏拉圖
|
||||
|
||||
**理由:** 使用者選擇特定 holdType 後,無關的柏拉圖顯示空資料會造成困惑。
|
||||
|
||||
### D6. 後端參數穿透策略
|
||||
|
||||
`get_hold_detail_summary` 和 `get_hold_detail_lots` 加入 5 個 Optional[str] 參數,傳遞至 `_select_with_snapshot_indexes()`(cache path)和 Oracle fallback。`get_wip_matrix` 已原生支援,只需在 route 層解析參數。
|
||||
|
||||
**理由:** 最小變更量,`_select_with_snapshot_indexes` 已支援全部篩選欄位。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[ParetoSection 搬遷路徑斷裂]** → 搬遷後需確認 WIP Overview 不再 import 舊路徑。透過 `npm run build` 驗證。
|
||||
- **[FilterPanel 跨目錄 import]** → 若 FilterPanel 內部路徑變更會影響 Hold Overview。風險低,該組件穩定。未來可考慮提升至 shared。
|
||||
- **[Hold Overview API 回應時間增加]** → 新增 FilterPanel 觸發 filter-options API。此 API 已有 debounce(120ms)和 cache,影響可忽略。
|
||||
- **[WIP Hold 卡片不再 toggle matrix]** → 使用者行為改變。但 Hold 卡片的 toggle 功能使用率低,跳轉到專頁更直觀。RUN/QUEUE 卡片保持原行為。
|
||||
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
Hold 即時概況版面單薄(僅 FilterBar + SummaryCards + Matrix + LotTable),缺乏視覺密度且篩選能力有限(只有 holdType + reason)。而 WIP 即時概況承載了兩張 Hold 柏拉圖,但使用者分析 Hold 數據時需在兩頁間來回切換。此次重構將 Hold 相關視覺化集中到 Hold 即時概況,並統一篩選器與 Lot 明細欄位,讓兩頁各司其職。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 將 WIP 即時概況的品質異常 / 非品質異常 Hold 柏拉圖移至 Hold 即時概況
|
||||
- WIP 即時概況的「品質異常」「非品質異常」StatusCard 點擊由篩選 matrix 改為跳轉至 Hold 即時概況(帶 hold_type 參數)
|
||||
- Hold 即時概況直接進入時 Hold Type 預設「全部」(原為「品質異常」)
|
||||
- Hold 即時概況加入 WIP 即時概況的 6 欄位 FilterPanel(workorder/lotid/package/type/firstname/waferdesc)
|
||||
- Hold Detail 返回按鈕改指 Hold 即時概況(原指 WIP 即時概況)
|
||||
- 統一 Hold 即時概況與 Hold Detail 的 Lot 明細欄位(合併 Hold Reason + Spec 為 13 欄)
|
||||
- Hold 即時概況版面修正(FilterBar 寬度過大、缺少 content-grid 包裹)
|
||||
- 後端 Hold Overview API 穿透 WIP 篩選參數(workorder/lotid/type/firstname/waferdesc)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_(無新增 capability)_
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `hold-overview-page`: Hold 即時概況加入柏拉圖、FilterPanel、預設 holdType 改為 all、版面修正、統一 LotTable
|
||||
- `wip-overview-page`: 移除 Hold 柏拉圖、Hold 卡片改為跳轉導航
|
||||
- `hold-detail-page`: 返回按鈕改指 Hold Overview、改用統一 LotTable
|
||||
|
||||
## Impact
|
||||
|
||||
- **前端**:`wip-overview/App.vue`(移除柏拉圖、卡片跳轉)、`hold-overview/App.vue`(核心重構)、`hold-detail/App.vue`(導航修正)、共用組件新增(ParetoSection 搬遷、HoldLotTable)、CSS 重構
|
||||
- **後端**:`hold_overview_routes.py`(3 API 加入篩選參數)、`wip_service.py`(2 函式簽名擴充)、`hold_routes.py`(redirect 改向)
|
||||
- **API**:`/api/hold-overview/summary`、`/api/hold-overview/matrix`、`/api/hold-overview/lots` 新增 workorder/lotid/type/firstname/waferdesc 可選參數(向後相容)
|
||||
@@ -0,0 +1,49 @@
|
||||
## MODIFIED 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 `/hold-overview`
|
||||
|
||||
### 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 13 columns: LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future 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 Hold Overview
|
||||
The page SHALL provide a way to return to the Hold Overview page.
|
||||
|
||||
#### Scenario: Back button
|
||||
- **WHEN** user clicks the "← Hold Overview" button in the header
|
||||
- **THEN** the page SHALL navigate to `/hold-overview`
|
||||
@@ -0,0 +1,126 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Hold Overview page SHALL display a filter bar with Hold Type and Reason
|
||||
The page SHALL provide a filter bar for selecting hold type and hold reason.
|
||||
|
||||
#### Scenario: Hold Type radio default
|
||||
- **WHEN** the page loads without a `hold_type` URL parameter
|
||||
- **THEN** the Hold Type filter SHALL default to "全部"
|
||||
- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部
|
||||
|
||||
#### Scenario: Hold Type from URL parameter
|
||||
- **WHEN** the page loads with `?hold_type=quality` or `?hold_type=non-quality`
|
||||
- **THEN** the Hold Type filter SHALL be set to the specified value
|
||||
|
||||
#### Scenario: Hold Type change reloads all data
|
||||
- **WHEN** user changes the Hold Type selection
|
||||
- **THEN** all API calls (summary, matrix, hold pareto, lots) SHALL reload with the new hold_type parameter
|
||||
- **THEN** any active matrix filters SHALL be cleared
|
||||
|
||||
#### Scenario: Reason dropdown populated from current data
|
||||
- **WHEN** summary data is loaded
|
||||
- **THEN** the Reason dropdown SHALL display "全部" plus all distinct hold reasons from the data
|
||||
- **THEN** selecting a specific reason SHALL reload all API calls filtered by that reason
|
||||
- **THEN** any active matrix filters SHALL be cleared
|
||||
|
||||
### Requirement: Hold Overview page SHALL display paginated lot details
|
||||
The page SHALL display a detailed lot table with server-side pagination.
|
||||
|
||||
#### Scenario: Lot table rendering
|
||||
- **WHEN** lot data is loaded from `GET /api/hold-overview/lots`
|
||||
- **THEN** a table SHALL display with 13 columns: LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future Hold Comment
|
||||
- **THEN** age values SHALL display with "天" suffix
|
||||
|
||||
#### Scenario: Lot table responds to all cascade filters
|
||||
- **WHEN** matrixFilter is `{ workcenter: WC-A, package: PKG-1 }`
|
||||
- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1`
|
||||
- **THEN** only lots matching all active filters SHALL be displayed
|
||||
|
||||
#### Scenario: Pagination
|
||||
- **WHEN** total lots exceeds per_page (50)
|
||||
- **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 changes (filter bar, matrix click, or WIP filter apply)
|
||||
- **THEN** pagination SHALL reset to page 1
|
||||
|
||||
#### Scenario: Empty lot result
|
||||
- **WHEN** a query returns zero lots
|
||||
- **THEN** the lot table SHALL display a "No data" placeholder
|
||||
|
||||
### Requirement: Hold Overview page SHALL have back navigation
|
||||
The page SHALL provide navigation back to WIP Overview.
|
||||
|
||||
#### Scenario: Back button
|
||||
- **WHEN** user clicks the "← WIP Overview" button in the header
|
||||
- **THEN** the page SHALL navigate to `/wip-overview`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hold Overview page SHALL display Hold Pareto analysis
|
||||
The page SHALL display Pareto charts for quality and non-quality hold reasons, fetched from `/api/wip/overview/hold`.
|
||||
|
||||
#### 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 using `splitHoldByType()`
|
||||
- **THEN** each group SHALL display an ECharts dual-axis Pareto chart (bar=QTY, line=cumulative %)
|
||||
- **THEN** items SHALL be sorted by QTY descending
|
||||
- **THEN** quality chart SHALL use red color (#ef4444), non-quality SHALL use orange (#f97316)
|
||||
|
||||
#### Scenario: Pareto visibility by holdType
|
||||
- **WHEN** holdType is "all"
|
||||
- **THEN** both quality and non-quality Pareto charts SHALL display
|
||||
- **WHEN** holdType is "quality"
|
||||
- **THEN** only the quality Pareto chart SHALL display
|
||||
- **WHEN** holdType is "non-quality"
|
||||
- **THEN** only the non-quality Pareto chart SHALL display
|
||||
|
||||
#### Scenario: Pareto chart drill-down
|
||||
- **WHEN** user clicks a bar in the Pareto chart or a reason link in the table
|
||||
- **THEN** the page SHALL navigate to `/hold-detail?reason={reason}`
|
||||
|
||||
#### Scenario: Empty hold data
|
||||
- **WHEN** a hold type has no items
|
||||
- **THEN** the chart area SHALL display a "目前無資料" message
|
||||
|
||||
### Requirement: Hold Overview page SHALL support WIP-style multi-field filtering
|
||||
The page SHALL provide the same 6-field FilterPanel as WIP Overview (workorder, lotid, package, type, firstname, waferdesc).
|
||||
|
||||
#### Scenario: FilterPanel rendering
|
||||
- **WHEN** the page loads
|
||||
- **THEN** a FilterPanel SHALL display with 6 multi-select fields: WORKORDER, LOT ID, PACKAGE, TYPE, Wafer LOT, Wafer Type
|
||||
- **THEN** filter options SHALL be loaded from `GET /api/wip/meta/filter-options` with `status=HOLD` and current holdType
|
||||
|
||||
#### Scenario: Apply filters
|
||||
- **WHEN** user selects filter values and clicks "套用篩選"
|
||||
- **THEN** all API calls (summary, matrix, hold pareto, lots) SHALL reload with the filter values
|
||||
- **THEN** the URL SHALL be updated to include the filter values
|
||||
|
||||
#### Scenario: Clear filters
|
||||
- **WHEN** user clicks "清除篩選"
|
||||
- **THEN** all filter inputs SHALL be cleared and data SHALL reload without WIP filters
|
||||
- **THEN** holdType and reason filters SHALL be preserved
|
||||
|
||||
#### Scenario: Filter options update
|
||||
- **WHEN** filters are changed (draft mode)
|
||||
- **THEN** filter options SHALL reload with debounce (120ms) reflecting cross-filter narrowing
|
||||
|
||||
### Requirement: Hold Overview API endpoints SHALL accept WIP filter parameters
|
||||
The backend Hold Overview endpoints SHALL support optional workorder, lotid, type, firstname, waferdesc query parameters.
|
||||
|
||||
#### Scenario: Summary API with WIP filters
|
||||
- **WHEN** `GET /api/hold-overview/summary?hold_type=quality&workorder=WO-001`
|
||||
- **THEN** the summary SHALL only include lots matching workorder WO-001
|
||||
|
||||
#### Scenario: Matrix API with WIP filters
|
||||
- **WHEN** `GET /api/hold-overview/matrix?hold_type=all&package=PKG-A`
|
||||
- **THEN** the matrix SHALL only include lots matching package PKG-A
|
||||
|
||||
#### Scenario: Lots API with WIP filters
|
||||
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&lotid=LOT-001`
|
||||
- **THEN** the lot list SHALL only include lots matching LOT ID LOT-001
|
||||
|
||||
#### Scenario: Backward compatibility
|
||||
- **WHEN** WIP filter parameters are omitted
|
||||
- **THEN** the API SHALL behave identically to the current implementation (no filtering)
|
||||
@@ -0,0 +1,28 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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: RUN/QUEUE card click filters matrix
|
||||
- **WHEN** user clicks the RUN or QUEUE status card
|
||||
- **THEN** the matrix table SHALL reload with the selected status filter
|
||||
- **THEN** the clicked card SHALL show an active visual state
|
||||
- **THEN** clicking the same card again SHALL deactivate the filter and restore all cards
|
||||
- **THEN** the URL SHALL be updated to reflect the active status filter
|
||||
|
||||
#### Scenario: Hold card click navigates to Hold Overview
|
||||
- **WHEN** user clicks the "品質異常" status card
|
||||
- **THEN** the page SHALL navigate to `/hold-overview?hold_type=quality`
|
||||
- **WHEN** user clicks the "非品質異常" status card
|
||||
- **THEN** the page SHALL navigate to `/hold-overview?hold_type=non-quality`
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Overview page SHALL display Hold Pareto analysis
|
||||
**Reason**: Hold Pareto charts are moved to the Hold Overview page where they are more relevant.
|
||||
**Migration**: Users can access the same Pareto analysis on the Hold Overview page. Clicking the "品質異常" or "非品質異常" status cards navigates directly there.
|
||||
@@ -0,0 +1,54 @@
|
||||
## 1. 後端:Hold Overview API 支援 WIP 篩選參數
|
||||
|
||||
- [x] 1.1 `wip_service.py` — `get_hold_detail_summary` 加入 workorder/lotid/pj_type/firstname/waferdesc 5 個 Optional[str] 參數,傳入 `_select_with_snapshot_indexes()` 和 Oracle fallback
|
||||
- [x] 1.2 `wip_service.py` — `get_hold_detail_lots` 加入同樣 5 個參數,傳入 `_select_with_snapshot_indexes()` 和 Oracle fallback
|
||||
- [x] 1.3 `hold_overview_routes.py` — 3 個 API (summary/matrix/lots) 從 request.args 解析 workorder/lotid/type/firstname/waferdesc 並傳入對應 service function
|
||||
|
||||
## 2. 共用基礎設施:ParetoSection 搬遷、CSS 抽取、HoldLotTable
|
||||
|
||||
- [x] 2.1 從 `wip-overview/style.css` 抽取 `.pareto-grid`、`.pareto-section`、`.pareto-header` 等柏拉圖相關 CSS 到新檔案 `wip-shared/pareto-styles.css`,原位改為 `@import`
|
||||
- [x] 2.2 將 `wip-overview/components/ParetoSection.vue` 移至 `wip-shared/components/ParetoSection.vue`,WIP Overview import 路徑更新
|
||||
- [x] 2.3 新增 `wip-shared/components/HoldLotTable.vue`:以 hold-overview LotTable 為基礎,新增 Spec 欄位形成 13 欄統一表格(LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future Hold Comment)
|
||||
|
||||
## 3. WIP 即時概況:移除柏拉圖 + Hold 卡片改跳轉
|
||||
|
||||
- [x] 3.1 `wip-overview/App.vue` — 移除 hold ref、fetchHold()、splitHold computed、navigateToHoldDetail()、template 的 `<section class="pareto-grid">` 區塊、對應 import (splitHoldByType, ParetoSection)
|
||||
- [x] 3.2 `wip-overview/App.vue` — 修改 `toggleStatusFilter()`:quality-hold → `navigateToRuntimeRoute('/hold-overview?hold_type=quality')`,non-quality-hold → `navigateToRuntimeRoute('/hold-overview?hold_type=non-quality')`,RUN/QUEUE 保持原行為
|
||||
|
||||
## 4. Hold 即時概況:加入柏拉圖
|
||||
|
||||
- [x] 4.1 `hold-overview/App.vue` — import ParetoSection(from wip-shared)、splitHoldByType(from wip-derive)、navigateToRuntimeRoute;新增 hold ref、fetchHold()、splitHold computed、showQualityPareto/showNonQualityPareto computed、navigateToHoldDetail()
|
||||
- [x] 4.2 `hold-overview/App.vue` — loadAllData 的 Promise.all 加入 fetchHold(signal),結果存入 hold.value
|
||||
- [x] 4.3 `hold-overview/App.vue` — Template 在 Matrix 後加入 pareto-grid 區段,根據 holdType 條件顯示品質異常 / 非品質異常柏拉圖
|
||||
- [x] 4.4 `hold-overview/style.css` — 頂部加入 `@import '../wip-shared/pareto-styles.css'`
|
||||
|
||||
## 5. Hold 即時概況:加入 WIP FilterPanel
|
||||
|
||||
- [x] 5.1 `hold-overview/App.vue` — import FilterPanel(from wip-overview/components)、buildWipOverviewQueryParams(from wip-derive);新增 filters reactive state + filterOptions ref + debounce 機制
|
||||
- [x] 5.2 `hold-overview/App.vue` — 新增 buildAllFilterParams() 合併 holdType/reason 與 WIP 6 欄位;更新 fetchSummary/fetchMatrix/fetchLots 改用 buildAllFilterParams()
|
||||
- [x] 5.3 `hold-overview/App.vue` — 新增 loadFilterOptions() 呼叫 `/api/wip/meta/filter-options`(帶 status=HOLD + holdType)、applyFilters()、clearAllFilters()、onFilterDraftChange()
|
||||
- [x] 5.4 `hold-overview/App.vue` — Template 在 FilterBar 前加入 FilterPanel 組件
|
||||
- [x] 5.5 `hold-overview/App.vue` — updateUrlState() 加入 6 欄位序列化;onMounted 解析 URL 的 WIP 篩選參數
|
||||
- [x] 5.6 `hold-overview/main.js` — 加入 `import '../resource-shared/styles.css'`(MultiSelect 需要)
|
||||
|
||||
## 6. Hold 即時概況:預設 holdType 改為 'all'
|
||||
|
||||
- [x] 6.1 `hold-overview/App.vue` — 所有 holdType 預設值和 fallback 從 'quality' 改為 'all'(filterBar initial、normalizeHoldType、buildFilterBarParams、handleFilterChange)
|
||||
- [x] 6.2 `hold-overview/components/FilterBar.vue` — 所有 holdType 預設值和 fallback 從 'quality' 改為 'all'
|
||||
|
||||
## 7. Hold Detail:返回按鈕改指 Hold Overview + 共用 LotTable
|
||||
|
||||
- [x] 7.1 `hold-detail/App.vue` — 返回按鈕 navigateToRuntimeRoute 從 '/wip-overview' 改為 '/hold-overview',按鈕文字改為 '← Hold Overview'
|
||||
- [x] 7.2 `hold-detail/App.vue` — no-reason redirect 從 '/wip-overview' 改為 '/hold-overview'
|
||||
- [x] 7.3 `hold-detail/App.vue` — import 改用 `HoldLotTable` from `'../wip-shared/components/HoldLotTable.vue'`,取代 `./components/LotTable.vue`
|
||||
- [x] 7.4 `hold_routes.py` — server-side redirect 從 '/wip-overview' 改為 '/hold-overview'
|
||||
|
||||
## 8. Hold 即時概況:版面修正 + 共用 LotTable
|
||||
|
||||
- [x] 8.1 `hold-overview/style.css` — `.hold-overview-hold-type-group` flex 改為 `0 0 auto`;`.hold-type-segment` min-width 改為 320px
|
||||
- [x] 8.2 `hold-overview/App.vue` — Template 加入 `.content-grid` wrapper 包裹 Matrix + Pareto + FilterIndicator + LotTable
|
||||
- [x] 8.3 `hold-overview/App.vue` — LotTable import 改用 `HoldLotTable` from `'../wip-shared/components/HoldLotTable.vue'`
|
||||
|
||||
## 9. 驗證
|
||||
|
||||
- [x] 9.1 前端 `npm run build` 通過(確認無 import 路徑斷裂)
|
||||
@@ -21,7 +21,7 @@ The page SHALL show summary statistics for a specific hold reason.
|
||||
|
||||
#### Scenario: Missing reason parameter
|
||||
- **WHEN** the page loads without a `reason` URL parameter
|
||||
- **THEN** the page SHALL redirect to `/wip-overview`
|
||||
- **THEN** the page SHALL redirect to `/hold-overview`
|
||||
|
||||
### Requirement: Hold Detail page SHALL display age distribution
|
||||
The page SHALL show the distribution of hold lots by age at current station.
|
||||
@@ -57,7 +57,7 @@ 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** a table SHALL display with 13 columns: LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future Hold Comment
|
||||
- **THEN** age values SHALL display with "天" suffix
|
||||
|
||||
#### Scenario: Filter indicator
|
||||
@@ -74,12 +74,12 @@ The page SHALL display detailed lot information with server-side 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.
|
||||
### Requirement: Hold Detail page SHALL have back navigation to Hold Overview
|
||||
The page SHALL provide a way to return to the Hold Overview page.
|
||||
|
||||
#### Scenario: Back button
|
||||
- **WHEN** user clicks the "← WIP Overview" button in the header
|
||||
- **THEN** the page SHALL navigate to `/wip-overview`
|
||||
- **WHEN** user clicks the "← Hold Overview" button in the header
|
||||
- **THEN** the page SHALL navigate to `/hold-overview`
|
||||
|
||||
### Requirement: Hold Detail page SHALL auto-refresh and handle request cancellation
|
||||
The page SHALL automatically refresh data and prevent stale request pile-up.
|
||||
|
||||
@@ -8,20 +8,24 @@ Define stable requirements for hold-overview-page.
|
||||
The page SHALL provide a filter bar for selecting hold type and hold reason.
|
||||
|
||||
#### Scenario: Hold Type radio default
|
||||
- **WHEN** the page loads
|
||||
- **THEN** the Hold Type filter SHALL default to "品質異常"
|
||||
- **WHEN** the page loads without a `hold_type` URL parameter
|
||||
- **THEN** the Hold Type filter SHALL default to "全部"
|
||||
- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部
|
||||
|
||||
#### Scenario: Hold Type from URL parameter
|
||||
- **WHEN** the page loads with `?hold_type=quality` or `?hold_type=non-quality`
|
||||
- **THEN** the Hold Type filter SHALL be set to the specified value
|
||||
|
||||
#### Scenario: Hold Type change reloads all data
|
||||
- **WHEN** user changes the Hold Type selection
|
||||
- **THEN** all four API calls (summary, matrix, treemap, lots) SHALL reload with the new hold_type parameter
|
||||
- **THEN** any active matrix and treemap filters SHALL be cleared
|
||||
- **THEN** all API calls (summary, matrix, hold pareto, lots) SHALL reload with the new hold_type parameter
|
||||
- **THEN** any active matrix filters SHALL be cleared
|
||||
|
||||
#### Scenario: Reason dropdown populated from current data
|
||||
- **WHEN** summary data is loaded
|
||||
- **THEN** the Reason dropdown SHALL display "全部" plus all distinct hold reasons from the treemap data
|
||||
- **THEN** selecting a specific reason SHALL reload all four API calls filtered by that reason
|
||||
- **THEN** any active matrix and treemap filters SHALL be cleared
|
||||
- **THEN** the Reason dropdown SHALL display "全部" plus all distinct hold reasons from the data
|
||||
- **THEN** selecting a specific reason SHALL reload all API calls filtered by that reason
|
||||
- **THEN** any active matrix filters SHALL be cleared
|
||||
|
||||
### Requirement: Hold Overview page SHALL display summary KPI cards
|
||||
The page SHALL show summary statistics for all hold lots matching the current filter.
|
||||
@@ -132,12 +136,12 @@ The page SHALL display a detailed lot table with server-side pagination.
|
||||
|
||||
#### Scenario: Lot table rendering
|
||||
- **WHEN** lot data is loaded from `GET /api/hold-overview/lots`
|
||||
- **THEN** a table SHALL display with columns: LOTID, WORKORDER, QTY, Package, Workcenter, Hold Reason, Age, Hold By, Dept, Hold Comment
|
||||
- **THEN** a table SHALL display with 13 columns: LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future Hold Comment
|
||||
- **THEN** age values SHALL display with "天" suffix
|
||||
|
||||
#### Scenario: Lot table responds to all cascade filters
|
||||
- **WHEN** matrixFilter is `{ workcenter: WC-A, package: PKG-1 }` and treemapFilter is `{ reason: 品質確認 }`
|
||||
- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1&treemap_reason=品質確認`
|
||||
- **WHEN** matrixFilter is `{ workcenter: WC-A, package: PKG-1 }`
|
||||
- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1`
|
||||
- **THEN** only lots matching all active filters SHALL be displayed
|
||||
|
||||
#### Scenario: Pagination
|
||||
@@ -146,7 +150,7 @@ The page SHALL display a detailed lot table with server-side pagination.
|
||||
- **THEN** page info SHALL show "顯示 {start} - {end} / {total}"
|
||||
|
||||
#### Scenario: Filter changes reset pagination
|
||||
- **WHEN** any filter changes (filter bar, matrix click, or treemap click)
|
||||
- **WHEN** any filter changes (filter bar, matrix click, or WIP filter apply)
|
||||
- **THEN** pagination SHALL reset to page 1
|
||||
|
||||
#### Scenario: Empty lot result
|
||||
@@ -198,3 +202,70 @@ The page SHALL provide navigation back to WIP Overview.
|
||||
#### Scenario: Back button
|
||||
- **WHEN** user clicks the "← WIP Overview" button in the header
|
||||
- **THEN** the page SHALL navigate to `/wip-overview`
|
||||
|
||||
### Requirement: Hold Overview page SHALL display Hold Pareto analysis
|
||||
The page SHALL display Pareto charts for quality and non-quality hold reasons, fetched from `/api/wip/overview/hold`.
|
||||
|
||||
#### 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 using `splitHoldByType()`
|
||||
- **THEN** each group SHALL display an ECharts dual-axis Pareto chart (bar=QTY, line=cumulative %)
|
||||
- **THEN** items SHALL be sorted by QTY descending
|
||||
- **THEN** quality chart SHALL use red color (#ef4444), non-quality SHALL use orange (#f97316)
|
||||
|
||||
#### Scenario: Pareto visibility by holdType
|
||||
- **WHEN** holdType is "all"
|
||||
- **THEN** both quality and non-quality Pareto charts SHALL display
|
||||
- **WHEN** holdType is "quality"
|
||||
- **THEN** only the quality Pareto chart SHALL display
|
||||
- **WHEN** holdType is "non-quality"
|
||||
- **THEN** only the non-quality Pareto chart SHALL display
|
||||
|
||||
#### Scenario: Pareto chart drill-down
|
||||
- **WHEN** user clicks a bar in the Pareto chart or a reason link in the table
|
||||
- **THEN** the page SHALL navigate to `/hold-detail?reason={reason}`
|
||||
|
||||
#### Scenario: Empty hold data
|
||||
- **WHEN** a hold type has no items
|
||||
- **THEN** the chart area SHALL display a "目前無資料" message
|
||||
|
||||
### Requirement: Hold Overview page SHALL support WIP-style multi-field filtering
|
||||
The page SHALL provide the same 6-field FilterPanel as WIP Overview (workorder, lotid, package, type, firstname, waferdesc).
|
||||
|
||||
#### Scenario: FilterPanel rendering
|
||||
- **WHEN** the page loads
|
||||
- **THEN** a FilterPanel SHALL display with 6 multi-select fields: WORKORDER, LOT ID, PACKAGE, TYPE, Wafer LOT, Wafer Type
|
||||
- **THEN** filter options SHALL be loaded from `GET /api/wip/meta/filter-options` with `status=HOLD` and current holdType
|
||||
|
||||
#### Scenario: Apply filters
|
||||
- **WHEN** user selects filter values and clicks "套用篩選"
|
||||
- **THEN** all API calls (summary, matrix, hold pareto, lots) SHALL reload with the filter values
|
||||
- **THEN** the URL SHALL be updated to include the filter values
|
||||
|
||||
#### Scenario: Clear filters
|
||||
- **WHEN** user clicks "清除篩選"
|
||||
- **THEN** all filter inputs SHALL be cleared and data SHALL reload without WIP filters
|
||||
- **THEN** holdType and reason filters SHALL be preserved
|
||||
|
||||
#### Scenario: Filter options update
|
||||
- **WHEN** filters are changed (draft mode)
|
||||
- **THEN** filter options SHALL reload with debounce (120ms) reflecting cross-filter narrowing
|
||||
|
||||
### Requirement: Hold Overview API endpoints SHALL accept WIP filter parameters
|
||||
The backend Hold Overview endpoints SHALL support optional workorder, lotid, type, firstname, waferdesc query parameters.
|
||||
|
||||
#### Scenario: Summary API with WIP filters
|
||||
- **WHEN** `GET /api/hold-overview/summary?hold_type=quality&workorder=WO-001`
|
||||
- **THEN** the summary SHALL only include lots matching workorder WO-001
|
||||
|
||||
#### Scenario: Matrix API with WIP filters
|
||||
- **WHEN** `GET /api/hold-overview/matrix?hold_type=all&package=PKG-A`
|
||||
- **THEN** the matrix SHALL only include lots matching package PKG-A
|
||||
|
||||
#### Scenario: Lots API with WIP filters
|
||||
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&lotid=LOT-001`
|
||||
- **THEN** the lot list SHALL only include lots matching LOT ID LOT-001
|
||||
|
||||
#### Scenario: Backward compatibility
|
||||
- **WHEN** WIP filter parameters are omitted
|
||||
- **THEN** the API SHALL behave identically to the current implementation (no filtering)
|
||||
|
||||
@@ -25,14 +25,19 @@ The page SHALL display four clickable status cards (RUN, QUEUE, 品質異常,
|
||||
- **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
|
||||
#### Scenario: RUN/QUEUE card click filters matrix
|
||||
- **WHEN** user clicks the RUN or QUEUE 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
|
||||
- **THEN** the URL SHALL be updated to reflect the active status filter
|
||||
|
||||
#### Scenario: Hold card click navigates to Hold Overview
|
||||
- **WHEN** user clicks the "品質異常" status card
|
||||
- **THEN** the page SHALL navigate to `/hold-overview?hold_type=quality`
|
||||
- **WHEN** user clicks the "非品質異常" status card
|
||||
- **THEN** the page SHALL navigate to `/hold-overview?hold_type=non-quality`
|
||||
|
||||
### Requirement: Overview page SHALL display Workcenter × Package matrix
|
||||
The page SHALL display a cross-tabulation table of workcenters vs packages.
|
||||
|
||||
@@ -48,29 +53,6 @@ The page SHALL display a cross-tabulation table of workcenters vs packages.
|
||||
- **THEN** active filter values (workorder, lotid, package, type) SHALL be passed as URL parameters
|
||||
- **THEN** the active status filter SHALL be passed as the `status` URL parameter if set
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ _VALID_HOLD_TYPES = {'quality', 'non-quality', 'all'}
|
||||
_VALID_AGE_RANGES = {'0-1', '1-3', '3-7', '7+'}
|
||||
|
||||
|
||||
def _parse_hold_type(default: str = 'quality') -> tuple[Optional[str], Optional[tuple[dict, int]]]:
|
||||
def _parse_hold_type(default: str = 'all') -> tuple[Optional[str], Optional[tuple[dict, int]]]:
|
||||
raw = request.args.get('hold_type', '').strip().lower()
|
||||
hold_type = raw or default
|
||||
if hold_type not in _VALID_HOLD_TYPES:
|
||||
@@ -79,16 +79,26 @@ def hold_overview_page():
|
||||
@hold_overview_bp.route('/api/hold-overview/summary')
|
||||
def api_hold_overview_summary():
|
||||
"""Return summary KPI data for hold overview page."""
|
||||
hold_type, error = _parse_hold_type(default='quality')
|
||||
hold_type, error = _parse_hold_type(default='all')
|
||||
if error:
|
||||
return jsonify(error[0]), error[1]
|
||||
|
||||
reason = request.args.get('reason', '').strip() or None
|
||||
workorder = request.args.get('workorder', '').strip() or None
|
||||
lotid = request.args.get('lotid', '').strip() or None
|
||||
pj_type = request.args.get('type', '').strip() or None
|
||||
firstname = request.args.get('firstname', '').strip() or None
|
||||
waferdesc = request.args.get('waferdesc', '').strip() or None
|
||||
include_dummy = parse_bool_query(request.args.get('include_dummy'))
|
||||
|
||||
result = get_hold_detail_summary(
|
||||
reason=reason,
|
||||
hold_type=hold_type,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
include_dummy=include_dummy,
|
||||
)
|
||||
if result is not None:
|
||||
@@ -100,11 +110,16 @@ def api_hold_overview_summary():
|
||||
@_HOLD_OVERVIEW_MATRIX_RATE_LIMIT
|
||||
def api_hold_overview_matrix():
|
||||
"""Return hold-only workcenter x package matrix."""
|
||||
hold_type, error = _parse_hold_type(default='quality')
|
||||
hold_type, error = _parse_hold_type(default='all')
|
||||
if error:
|
||||
return jsonify(error[0]), error[1]
|
||||
|
||||
reason = request.args.get('reason', '').strip() or None
|
||||
workorder = request.args.get('workorder', '').strip() or None
|
||||
lotid = request.args.get('lotid', '').strip() or None
|
||||
pj_type = request.args.get('type', '').strip() or None
|
||||
firstname = request.args.get('firstname', '').strip() or None
|
||||
waferdesc = request.args.get('waferdesc', '').strip() or None
|
||||
include_dummy = parse_bool_query(request.args.get('include_dummy'))
|
||||
|
||||
result = get_wip_matrix(
|
||||
@@ -112,6 +127,11 @@ def api_hold_overview_matrix():
|
||||
status='HOLD',
|
||||
hold_type=hold_type,
|
||||
reason=reason,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
)
|
||||
if result is not None:
|
||||
return jsonify({'success': True, 'data': result})
|
||||
@@ -121,7 +141,7 @@ def api_hold_overview_matrix():
|
||||
@hold_overview_bp.route('/api/hold-overview/treemap')
|
||||
def api_hold_overview_treemap():
|
||||
"""Return grouped hold overview data for treemap chart."""
|
||||
hold_type, error = _parse_hold_type(default='quality')
|
||||
hold_type, error = _parse_hold_type(default='all')
|
||||
if error:
|
||||
return jsonify(error[0]), error[1]
|
||||
|
||||
@@ -146,7 +166,7 @@ def api_hold_overview_treemap():
|
||||
@_HOLD_OVERVIEW_LOTS_RATE_LIMIT
|
||||
def api_hold_overview_lots():
|
||||
"""Return paginated hold lot details."""
|
||||
hold_type, error = _parse_hold_type(default='quality')
|
||||
hold_type, error = _parse_hold_type(default='all')
|
||||
if error:
|
||||
return jsonify(error[0]), error[1]
|
||||
|
||||
@@ -154,6 +174,11 @@ def api_hold_overview_lots():
|
||||
treemap_reason = request.args.get('treemap_reason', '').strip() or None
|
||||
workcenter = request.args.get('workcenter', '').strip() or None
|
||||
package = request.args.get('package', '').strip() or None
|
||||
workorder = request.args.get('workorder', '').strip() or None
|
||||
lotid = request.args.get('lotid', '').strip() or None
|
||||
pj_type = request.args.get('type', '').strip() or None
|
||||
firstname = request.args.get('firstname', '').strip() or None
|
||||
waferdesc = request.args.get('waferdesc', '').strip() or None
|
||||
age_range = request.args.get('age_range', '').strip() or None
|
||||
include_dummy = parse_bool_query(request.args.get('include_dummy'))
|
||||
page = request.args.get('page', 1, type=int)
|
||||
@@ -179,6 +204,11 @@ def api_hold_overview_lots():
|
||||
treemap_reason=treemap_reason,
|
||||
workcenter=workcenter,
|
||||
package=package,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
age_range=age_range,
|
||||
include_dummy=include_dummy,
|
||||
page=page,
|
||||
|
||||
@@ -50,10 +50,10 @@ def hold_detail_page():
|
||||
reason = request.args.get('reason', '').strip()
|
||||
if not reason:
|
||||
# Redirect to overview route; in SPA mode this becomes canonical shell URL.
|
||||
overview_redirect = maybe_redirect_to_canonical_shell('/wip-overview')
|
||||
overview_redirect = maybe_redirect_to_canonical_shell('/hold-overview')
|
||||
if overview_redirect is not None:
|
||||
return overview_redirect
|
||||
return redirect('/wip-overview')
|
||||
return redirect('/hold-overview')
|
||||
|
||||
canonical_redirect = maybe_redirect_to_canonical_shell('/hold-detail')
|
||||
if canonical_redirect is not None:
|
||||
|
||||
@@ -2581,6 +2581,11 @@ def _search_types_from_oracle(
|
||||
def get_hold_detail_summary(
|
||||
reason: Optional[str] = None,
|
||||
hold_type: Optional[str] = None,
|
||||
workorder: Optional[str] = None,
|
||||
lotid: Optional[str] = None,
|
||||
pj_type: Optional[str] = None,
|
||||
firstname: Optional[str] = None,
|
||||
waferdesc: Optional[str] = None,
|
||||
include_dummy: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get summary statistics for hold lots.
|
||||
@@ -2590,6 +2595,11 @@ def get_hold_detail_summary(
|
||||
Args:
|
||||
reason: Optional HOLDREASONNAME filter
|
||||
hold_type: Optional hold type filter ('quality', 'non-quality')
|
||||
workorder: Optional WORKORDER filter
|
||||
lotid: Optional LOTID filter
|
||||
pj_type: Optional PJ_TYPE filter
|
||||
firstname: Optional FIRSTNAME filter
|
||||
waferdesc: Optional WAFERDESC filter
|
||||
include_dummy: If True, include DUMMY lots (default: False)
|
||||
|
||||
Returns:
|
||||
@@ -2601,6 +2611,11 @@ def get_hold_detail_summary(
|
||||
try:
|
||||
df = _select_with_snapshot_indexes(
|
||||
include_dummy=include_dummy,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
status='HOLD',
|
||||
hold_type=hold_type,
|
||||
)
|
||||
@@ -2608,6 +2623,11 @@ def get_hold_detail_summary(
|
||||
return _get_hold_detail_summary_from_oracle(
|
||||
reason=reason,
|
||||
hold_type=hold_type,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
include_dummy=include_dummy,
|
||||
)
|
||||
|
||||
@@ -2645,6 +2665,11 @@ def get_hold_detail_summary(
|
||||
return _get_hold_detail_summary_from_oracle(
|
||||
reason=reason,
|
||||
hold_type=hold_type,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
include_dummy=include_dummy,
|
||||
)
|
||||
|
||||
@@ -2652,6 +2677,11 @@ def get_hold_detail_summary(
|
||||
def _get_hold_detail_summary_from_oracle(
|
||||
reason: Optional[str] = None,
|
||||
hold_type: Optional[str] = None,
|
||||
workorder: Optional[str] = None,
|
||||
lotid: Optional[str] = None,
|
||||
pj_type: Optional[str] = None,
|
||||
firstname: Optional[str] = None,
|
||||
waferdesc: Optional[str] = None,
|
||||
include_dummy: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get hold detail summary directly from Oracle (fallback)."""
|
||||
@@ -2663,6 +2693,16 @@ def _get_hold_detail_summary_from_oracle(
|
||||
_add_hold_type_conditions(builder, hold_type)
|
||||
if reason:
|
||||
builder.add_param_condition("HOLDREASONNAME", reason)
|
||||
if workorder:
|
||||
builder.add_like_condition("WORKORDER", workorder)
|
||||
if lotid:
|
||||
builder.add_like_condition("LOTID", lotid)
|
||||
if pj_type:
|
||||
builder.add_param_condition("PJ_TYPE", pj_type)
|
||||
if firstname:
|
||||
builder.add_param_condition("FIRSTNAME", firstname)
|
||||
if waferdesc:
|
||||
builder.add_param_condition("WAFERDESC", waferdesc)
|
||||
where_clause, params = builder.build_where_only()
|
||||
|
||||
sql = f"""
|
||||
@@ -2988,6 +3028,11 @@ def get_hold_detail_lots(
|
||||
treemap_reason: Optional[str] = None,
|
||||
workcenter: Optional[str] = None,
|
||||
package: Optional[str] = None,
|
||||
workorder: Optional[str] = None,
|
||||
lotid: Optional[str] = None,
|
||||
pj_type: Optional[str] = None,
|
||||
firstname: Optional[str] = None,
|
||||
waferdesc: Optional[str] = None,
|
||||
age_range: Optional[str] = None,
|
||||
include_dummy: bool = False,
|
||||
page: int = 1,
|
||||
@@ -3003,6 +3048,11 @@ def get_hold_detail_lots(
|
||||
treemap_reason: Optional HOLDREASONNAME filter from treemap selection
|
||||
workcenter: Optional WORKCENTER_GROUP filter
|
||||
package: Optional PACKAGE_LEF filter
|
||||
workorder: Optional WORKORDER filter
|
||||
lotid: Optional LOTID filter
|
||||
pj_type: Optional PJ_TYPE filter
|
||||
firstname: Optional FIRSTNAME filter
|
||||
waferdesc: Optional WAFERDESC filter
|
||||
age_range: Optional age range filter ('0-1', '1-3', '3-7', '7+')
|
||||
include_dummy: If True, include DUMMY lots (default: False)
|
||||
page: Page number (1-based)
|
||||
@@ -3022,6 +3072,11 @@ def get_hold_detail_lots(
|
||||
include_dummy=include_dummy,
|
||||
workcenter=workcenter,
|
||||
package=package,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
status='HOLD',
|
||||
hold_type=hold_type,
|
||||
)
|
||||
@@ -3032,6 +3087,11 @@ def get_hold_detail_lots(
|
||||
treemap_reason=treemap_reason,
|
||||
workcenter=workcenter,
|
||||
package=package,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
age_range=age_range,
|
||||
include_dummy=include_dummy,
|
||||
page=page,
|
||||
@@ -3116,6 +3176,11 @@ def get_hold_detail_lots(
|
||||
treemap_reason=treemap_reason,
|
||||
workcenter=workcenter,
|
||||
package=package,
|
||||
workorder=workorder,
|
||||
lotid=lotid,
|
||||
pj_type=pj_type,
|
||||
firstname=firstname,
|
||||
waferdesc=waferdesc,
|
||||
age_range=age_range,
|
||||
include_dummy=include_dummy,
|
||||
page=page,
|
||||
@@ -3129,6 +3194,11 @@ def _get_hold_detail_lots_from_oracle(
|
||||
treemap_reason: Optional[str] = None,
|
||||
workcenter: Optional[str] = None,
|
||||
package: Optional[str] = None,
|
||||
workorder: Optional[str] = None,
|
||||
lotid: Optional[str] = None,
|
||||
pj_type: Optional[str] = None,
|
||||
firstname: Optional[str] = None,
|
||||
waferdesc: Optional[str] = None,
|
||||
age_range: Optional[str] = None,
|
||||
include_dummy: bool = False,
|
||||
page: int = 1,
|
||||
@@ -3151,6 +3221,16 @@ def _get_hold_detail_lots_from_oracle(
|
||||
builder.add_param_condition("WORKCENTER_GROUP", workcenter)
|
||||
if package:
|
||||
builder.add_param_condition("PACKAGE_LEF", package)
|
||||
if workorder:
|
||||
builder.add_like_condition("WORKORDER", workorder)
|
||||
if lotid:
|
||||
builder.add_like_condition("LOTID", lotid)
|
||||
if pj_type:
|
||||
builder.add_param_condition("PJ_TYPE", pj_type)
|
||||
if firstname:
|
||||
builder.add_param_condition("FIRSTNAME", firstname)
|
||||
if waferdesc:
|
||||
builder.add_param_condition("WAFERDESC", waferdesc)
|
||||
if age_range:
|
||||
if age_range == '0-1':
|
||||
builder.add_condition("AGEBYDAYS >= 0 AND AGEBYDAYS < 1")
|
||||
|
||||
Reference in New Issue
Block a user