feat: 新增 WIP 狀態篩選功能 - 點擊卡片快速篩選 RUN/QUEUE/HOLD

- 後端 API 支援 status 參數篩選 (RUN/QUEUE/HOLD)
- WIP Overview: 點擊狀態卡片可篩選 Matrix 顯示
- WIP Detail: 移除舊 Status 下拉選單,改用卡片篩選
- 加入 AbortController 防止快速切換時請求堆積導致 timeout
- 選中卡片視覺強化 (放大、深色背景、陰影)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-27 20:30:19 +08:00
parent fcfa4fb919
commit cbc19c9920
4 changed files with 361 additions and 36 deletions

View File

@@ -65,6 +65,7 @@ def api_overview_matrix():
workorder: Optional WORKORDER filter (fuzzy match)
lotid: Optional LOTID filter (fuzzy match)
include_dummy: Include DUMMY lots (default: false)
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
Returns:
JSON with workcenters, packages, matrix, workcenter_totals,
@@ -73,11 +74,20 @@ def api_overview_matrix():
workorder = request.args.get('workorder', '').strip() or None
lotid = request.args.get('lotid', '').strip() or None
include_dummy = _parse_bool(request.args.get('include_dummy', ''))
status = request.args.get('status', '').strip().upper() or None
# Validate status parameter
if status and status not in ('RUN', 'QUEUE', 'HOLD'):
return jsonify({
'success': False,
'error': 'Invalid status. Use RUN, QUEUE, or HOLD'
}), 400
result = get_wip_matrix(
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid
lotid=lotid,
status=status
)
if result is not None:
return jsonify({'success': True, 'data': result})
@@ -123,7 +133,7 @@ def api_detail(workcenter: str):
Query Parameters:
package: Optional PRODUCTLINENAME filter
status: Optional STATUS filter ('ACTIVE', 'HOLD')
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
workorder: Optional WORKORDER filter (fuzzy match)
lotid: Optional LOTID filter (fuzzy match)
include_dummy: Include DUMMY lots (default: false)
@@ -134,7 +144,7 @@ def api_detail(workcenter: str):
JSON with workcenter, summary, specs, lots, pagination, sys_date
"""
package = request.args.get('package', '').strip() or None
status = request.args.get('status', '').strip() or None
status = request.args.get('status', '').strip().upper() or None
workorder = request.args.get('workorder', '').strip() or None
lotid = request.args.get('lotid', '').strip() or None
include_dummy = _parse_bool(request.args.get('include_dummy', ''))
@@ -144,6 +154,13 @@ def api_detail(workcenter: str):
if page < 1:
page = 1
# Validate status parameter
if status and status not in ('RUN', 'QUEUE', 'HOLD'):
return jsonify({
'success': False,
'error': 'Invalid status. Use RUN, QUEUE, or HOLD'
}), 400
result = get_wip_detail(
workcenter=workcenter,
package=package,

View File

@@ -143,7 +143,8 @@ def get_wip_summary(
def get_wip_matrix(
include_dummy: bool = False,
workorder: Optional[str] = None,
lotid: Optional[str] = None
lotid: Optional[str] = None,
status: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Get workcenter x product line matrix for overview dashboard.
@@ -151,6 +152,7 @@ def get_wip_matrix(
include_dummy: If True, include DUMMY lots (default: False)
workorder: Optional WORKORDER filter (fuzzy match)
lotid: Optional LOTID filter (fuzzy match)
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
Returns:
Dict with matrix data:
@@ -165,6 +167,16 @@ def get_wip_matrix(
conditions = _build_base_conditions(include_dummy, workorder, lotid)
conditions.append("WORKCENTER_GROUP IS NOT NULL")
conditions.append("PRODUCTLINENAME IS NOT NULL")
# WIP status filter
if status:
status_upper = status.upper()
if status_upper == 'RUN':
conditions.append("EQUIPMENTCOUNT > 0")
elif status_upper == 'HOLD':
conditions.append("EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT > 0")
elif status_upper == 'QUEUE':
conditions.append("EQUIPMENTCOUNT = 0 AND CURRENTHOLDCOUNT = 0")
where_clause = f"WHERE {' AND '.join(conditions)}"
sql = f"""
@@ -303,7 +315,7 @@ def get_wip_detail(
Args:
workcenter: WORKCENTER_GROUP name
package: Optional PRODUCTLINENAME filter
status: Optional STATUS filter ('ACTIVE', 'HOLD')
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
workorder: Optional WORKORDER filter (fuzzy match)
lotid: Optional LOTID filter (fuzzy match)
include_dummy: If True, include DUMMY lots (default: False)
@@ -313,7 +325,7 @@ def get_wip_detail(
Returns:
Dict with:
- workcenter: The workcenter group name
- summary: {total_lots, on_equipment_lots, waiting_lots, hold_lots}
- summary: {totalLots, runLots, queueLots, holdLots}
- specs: List of spec names (sorted by SPECSEQUENCE)
- lots: List of lot details
- pagination: {page, page_size, total_count, total_pages}
@@ -327,8 +339,15 @@ def get_wip_detail(
if package:
conditions.append(f"PRODUCTLINENAME = '{_escape_sql(package)}'")
# WIP status filter (RUN/QUEUE/HOLD based on EQUIPMENTCOUNT and CURRENTHOLDCOUNT)
if status:
conditions.append(f"STATUS = '{_escape_sql(status)}'")
status_upper = status.upper()
if status_upper == 'RUN':
conditions.append("COALESCE(EQUIPMENTCOUNT, 0) > 0")
elif status_upper == 'HOLD':
conditions.append("COALESCE(EQUIPMENTCOUNT, 0) = 0 AND COALESCE(CURRENTHOLDCOUNT, 0) > 0")
elif status_upper == 'QUEUE':
conditions.append("COALESCE(EQUIPMENTCOUNT, 0) = 0 AND COALESCE(CURRENTHOLDCOUNT, 0) = 0")
where_clause = f"WHERE {' AND '.join(conditions)}"

View File

@@ -328,7 +328,20 @@
animation: valueUpdate 0.5s ease;
}
/* Status Card Colors */
/* Status Card Colors - Clickable */
.summary-card.status-run,
.summary-card.status-queue,
.summary-card.status-hold {
cursor: pointer;
transition: all 0.2s ease;
}
.summary-card.status-run:hover,
.summary-card.status-queue:hover,
.summary-card.status-hold:hover {
transform: translateY(-2px);
}
.summary-card.status-run {
background: #F0FDF4;
border-color: #22C55E;
@@ -336,6 +349,15 @@
.summary-card.status-run .summary-value {
color: #166534;
}
.summary-card.status-run:hover {
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.25);
}
.summary-card.status-run.active {
background: #DCFCE7;
border-width: 3px;
transform: scale(1.03);
box-shadow: 0 6px 25px rgba(34, 197, 94, 0.5);
}
.summary-card.status-queue {
background: #FFFBEB;
@@ -344,6 +366,15 @@
.summary-card.status-queue .summary-value {
color: #92400E;
}
.summary-card.status-queue:hover {
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.25);
}
.summary-card.status-queue.active {
background: #FEF3C7;
border-width: 3px;
transform: scale(1.03);
box-shadow: 0 6px 25px rgba(245, 158, 11, 0.5);
}
.summary-card.status-hold {
background: #FEF2F2;
@@ -352,6 +383,22 @@
.summary-card.status-hold .summary-value {
color: #991B1B;
}
.summary-card.status-hold:hover {
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.25);
}
.summary-card.status-hold.active {
background: #FEE2E2;
border-width: 3px;
transform: scale(1.03);
box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
}
/* Dim non-active cards when filtering */
.summary-row.filtering .summary-card.status-run:not(.active),
.summary-row.filtering .summary-card.status-queue:not(.active),
.summary-row.filtering .summary-card.status-hold:not(.active) {
opacity: 0.5;
}
@keyframes valueUpdate {
0% { transform: scale(1); }
@@ -610,33 +657,25 @@
<option value="">All</option>
</select>
</div>
<div class="filter-group">
<label>Status</label>
<select id="filterStatus" onchange="applyFilters()">
<option value="">All</option>
<option value="ACTIVE">Active</option>
<option value="HOLD">Hold</option>
</select>
</div>
<button class="btn-primary" onclick="applyFilters()">Apply</button>
<button class="btn-secondary" onclick="clearFilters()">Clear</button>
</div>
<!-- Summary Cards -->
<div class="summary-row">
<div class="summary-row" id="summaryRow">
<div class="summary-card">
<div class="summary-label">Total Lots</div>
<div class="summary-value" id="totalLots">-</div>
</div>
<div class="summary-card status-run">
<div class="summary-card status-run" onclick="toggleStatusFilter('run')" title="Click to filter RUN only">
<div class="summary-label">RUN</div>
<div class="summary-value" id="runLots">-</div>
</div>
<div class="summary-card status-queue">
<div class="summary-card status-queue" onclick="toggleStatusFilter('queue')" title="Click to filter QUEUE only">
<div class="summary-label">QUEUE</div>
<div class="summary-value" id="queueLots">-</div>
</div>
<div class="summary-card status-hold">
<div class="summary-card status-hold" onclick="toggleStatusFilter('hold')" title="Click to filter HOLD only">
<div class="summary-label">HOLD</div>
<div class="summary-value" id="holdLots">-</div>
</div>
@@ -677,7 +716,6 @@
pageSize: 100,
filters: {
package: '',
status: '',
workorder: '',
lotid: ''
},
@@ -687,6 +725,12 @@
searchDebounceTimers: {}
};
// WIP Status filter (separate from other filters)
let activeStatusFilter = null; // null | 'run' | 'queue' | 'hold'
// AbortController for cancelling in-flight table requests
let tableAbortController = null;
// ============================================================
// Utility Functions
// ============================================================
@@ -717,10 +761,15 @@
// ============================================================
const API_TIMEOUT = 60000; // 60 seconds timeout
async function fetchWithTimeout(url, timeout = API_TIMEOUT) {
async function fetchWithTimeout(url, timeout = API_TIMEOUT, externalSignal = null) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// If external signal is provided, abort when it fires
if (externalSignal) {
externalSignal.addEventListener('abort', () => controller.abort());
}
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
@@ -728,6 +777,10 @@
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
// Check if it was external cancellation or timeout
if (externalSignal && externalSignal.aborted) {
throw error; // Re-throw AbortError for external cancellation
}
throw new Error('Request timeout');
}
throw error;
@@ -743,7 +796,7 @@
throw new Error(result.error || 'Failed to fetch packages');
}
async function fetchDetail() {
async function fetchDetail(signal = null) {
const params = new URLSearchParams({
page: state.page,
page_size: state.pageSize
@@ -752,8 +805,9 @@
if (state.filters.package) {
params.append('package', state.filters.package);
}
if (state.filters.status) {
params.append('status', state.filters.status);
if (activeStatusFilter) {
// Convert to API status format (RUN/QUEUE/HOLD)
params.append('status', activeStatusFilter.toUpperCase());
}
if (state.filters.workorder) {
params.append('workorder', state.filters.workorder);
@@ -762,7 +816,7 @@
params.append('lotid', state.filters.lotid);
}
const response = await fetchWithTimeout(`/api/wip/detail/${encodeURIComponent(state.workcenter)}?${params}`);
const response = await fetchWithTimeout(`/api/wip/detail/${encodeURIComponent(state.workcenter)}?${params}`, API_TIMEOUT, signal);
const result = await response.json();
if (result.success) {
return result.data;
@@ -1033,6 +1087,103 @@
});
}
// ============================================================
// 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-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) {
const statusLabel = activeStatusFilter.toUpperCase();
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
} else {
titleEl.textContent = baseTitle;
}
}
// ============================================================
// Filter & Pagination
// ============================================================
@@ -1040,7 +1191,6 @@
state.filters.workorder = document.getElementById('filterWorkorder').value.trim();
state.filters.lotid = document.getElementById('filterLotid').value.trim();
state.filters.package = document.getElementById('filterPackage').value;
state.filters.status = document.getElementById('filterStatus').value;
state.page = 1;
loadAllData(false);
}
@@ -1049,8 +1199,13 @@
document.getElementById('filterWorkorder').value = '';
document.getElementById('filterLotid').value = '';
document.getElementById('filterPackage').value = '';
document.getElementById('filterStatus').value = '';
state.filters = { package: '', status: '', workorder: '', lotid: '' };
state.filters = { package: '', workorder: '', lotid: '' };
// Also clear status filter
activeStatusFilter = null;
updateCardStyles();
updateTableTitle();
state.page = 1;
loadAllData(false);
}

View File

@@ -388,6 +388,42 @@
font-weight: 800;
}
/* Clickable status cards */
.wip-status-card {
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.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.hold.active {
background: #FEE2E2;
box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
}
/* Dim non-active cards when filtering */
.wip-status-row.filtering .wip-status-card:not(.active) {
opacity: 0.5;
}
/* Content Grid */
.content-grid {
display: grid;
@@ -647,21 +683,21 @@
<!-- WIP Status Cards -->
<div class="wip-status-row">
<div class="wip-status-card run">
<div class="wip-status-card run" onclick="toggleStatusFilter('run')">
<div class="status-header"><span class="dot"></span>RUN</div>
<div class="status-values">
<span id="runLots">-</span>
<span id="runQty">-</span>
</div>
</div>
<div class="wip-status-card queue">
<div class="wip-status-card queue" onclick="toggleStatusFilter('queue')">
<div class="status-header"><span class="dot"></span>QUEUE</div>
<div class="status-values">
<span id="queueLots">-</span>
<span id="queueQty">-</span>
</div>
</div>
<div class="wip-status-card hold">
<div class="wip-status-card hold" onclick="toggleStatusFilter('hold')">
<div class="status-header"><span class="dot"></span>HOLD</div>
<div class="status-values">
<span id="holdLots">-</span>
@@ -726,6 +762,12 @@
}
};
// Status filter state (null = no filter, 'run'/'queue'/'hold' = filtered)
let activeStatusFilter = null;
// AbortController for cancelling in-flight matrix requests
let matrixAbortController = null;
// ============================================================
// Utility Functions
// ============================================================
@@ -906,10 +948,15 @@
// ============================================================
const API_TIMEOUT = 60000; // 60 seconds timeout
async function fetchWithTimeout(url, timeout = API_TIMEOUT) {
async function fetchWithTimeout(url, timeout = API_TIMEOUT, externalSignal = null) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// If external signal is provided, abort when it fires
if (externalSignal) {
externalSignal.addEventListener('abort', () => controller.abort());
}
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
@@ -917,6 +964,10 @@
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
// Check if it was external cancellation or timeout
if (externalSignal && externalSignal.aborted) {
throw error; // Re-throw AbortError for external cancellation
}
throw new Error('Request timeout');
}
throw error;
@@ -934,10 +985,21 @@
throw new Error(result.error || 'Failed to fetch summary');
}
async function fetchMatrix() {
const queryParams = buildQueryParams();
async function fetchMatrix(signal = null) {
const params = new URLSearchParams();
if (state.filters.workorder) {
params.append('workorder', state.filters.workorder);
}
if (state.filters.lotid) {
params.append('lotid', state.filters.lotid);
}
// Add status filter if active
if (activeStatusFilter) {
params.append('status', activeStatusFilter.toUpperCase());
}
const queryParams = params.toString();
const url = `/api/wip/overview/matrix${queryParams ? '?' + queryParams : ''}`;
const response = await fetchWithTimeout(url);
const response = await fetchWithTimeout(url, API_TIMEOUT, signal);
const result = await response.json();
if (result.success) {
return result.data;
@@ -1003,6 +1065,78 @@
}
}
// ============================================================
// 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) {
const 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');