Files
DashBoard/src/mes_dashboard/templates/wip_overview.html
beabigegg c8fc749bbd feat: 新增 Hold Detail 頁面與修復 WIP Detail 篩選問題
Hold Detail 功能:
- 新增 /hold-detail 頁面,點擊 Hold Reason 可查看詳細資料
- 顯示摘要統計(總批次、總數量、平均/最大滯留天數、站點數)
- 按站點、封裝類型、滯留天數分佈展示
- Lot Details 表格含分頁與篩選功能
- 新增 Hold Comment 欄位顯示

WIP Detail 修復:
- 修復狀態卡片篩選後分頁總數仍顯示未篩選數量的問題
- 現在篩選 RUN/QUEUE/HOLD 後,分頁正確顯示篩選後的總數

測試: 120 passed (20 hold routes + 67 wip routes/service)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 13:38:15 +08:00

1395 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "_base.html" %}
{% block title %}WIP Overview Dashboard{% endblock %}
{% block head_extra %}
<style>
:root {
--bg: #f5f7fa;
--card-bg: #ffffff;
--text: #222;
--muted: #666;
--border: #e2e6ef;
--primary: #667eea;
--primary-dark: #5568d3;
--shadow: 0 2px 10px rgba(0,0,0,0.08);
--shadow-strong: 0 4px 15px rgba(102, 126, 234, 0.2);
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.dashboard {
max-width: 1900px;
margin: 0 auto;
padding: 20px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 18px 22px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
margin-bottom: 16px;
box-shadow: var(--shadow-strong);
}
.header h1 {
font-size: 24px;
color: #fff;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.last-update {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.refresh-indicator {
display: none;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.refresh-indicator.active {
display: inline-block;
}
.refresh-success {
color: #22c55e;
display: none;
}
.refresh-success.active {
display: inline-block;
animation: fadeOut 1s ease-out forwards;
}
.refresh-error {
width: 8px;
height: 8px;
background: var(--danger);
border-radius: 50%;
display: none;
}
.refresh-error.active {
display: inline-block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeOut {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
.btn {
padding: 9px 20px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-light {
background: rgba(255,255,255,0.2);
color: white;
}
.btn-light:hover {
background: rgba(255,255,255,0.3);
}
/* Filters */
.filters {
background: var(--card-bg);
padding: 16px 20px;
border-radius: 10px;
margin-bottom: 16px;
box-shadow: var(--shadow);
display: flex;
gap: 16px;
align-items: flex-end;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
}
.filter-group label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.filter-group input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
min-width: 200px;
}
.filter-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.filter-group .search-loading {
position: absolute;
right: 10px;
top: 32px;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: none;
}
.filter-group .search-loading.active {
display: block;
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: var(--shadow);
max-height: 200px;
overflow-y: auto;
z-index: 100;
display: none;
}
.autocomplete-dropdown.active {
display: block;
}
.autocomplete-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.autocomplete-item:hover {
background: #f3f4f6;
}
.autocomplete-item.no-results {
color: var(--muted);
cursor: default;
}
.btn-primary {
padding: 9px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-secondary {
padding: 9px 20px;
background: #6c757d;
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-secondary:hover {
background: #5a6268;
}
.filter-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #e8ecff;
color: var(--primary);
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
.filter-tag .remove {
cursor: pointer;
font-weight: bold;
}
/* Summary Cards */
.summary-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.summary-card {
background: var(--card-bg);
border-radius: 10px;
padding: 16px 20px;
text-align: center;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.summary-label {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.summary-value {
font-size: 28px;
font-weight: bold;
color: var(--primary);
transition: all 0.3s ease;
}
.summary-value.highlight {
color: var(--danger);
}
.summary-value.updated {
animation: valueUpdate 0.5s ease;
}
@keyframes valueUpdate {
0% { transform: scale(1); }
50% { transform: scale(1.05); background: rgba(102, 126, 234, 0.1); }
100% { transform: scale(1); }
}
/* WIP Status Cards */
.wip-status-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.wip-status-card {
background: var(--card-bg);
border-radius: 10px;
padding: 12px 16px;
border: 2px solid;
box-shadow: var(--shadow);
}
.wip-status-card.run {
background: #F0FDF4;
border-color: #22C55E;
}
.wip-status-card.queue {
background: #FFFBEB;
border-color: #F59E0B;
}
.wip-status-card.quality-hold {
background: #FEF2F2;
border-color: #EF4444;
}
.wip-status-card.non-quality-hold {
background: #FFF7ED;
border-color: #F97316;
}
.wip-status-card .status-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
margin-bottom: 8px;
}
.wip-status-card.run .status-header { color: #166534; }
.wip-status-card.queue .status-header { color: #92400E; }
.wip-status-card.quality-hold .status-header { color: #991B1B; }
.wip-status-card.non-quality-hold .status-header { color: #9A3412; }
.wip-status-card .status-header .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.wip-status-card .status-values {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
}
.wip-status-card .status-values span {
font-size: 24px;
font-weight: 700;
color: var(--text);
}
.wip-status-card .status-values span:first-child {
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.quality-hold.active {
background: #FEE2E2;
box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
}
.wip-status-card.non-quality-hold.active {
background: #FFEDD5;
box-shadow: 0 6px 25px rgba(249, 115, 22, 0.5);
}
/* Dim non-active cards when filtering */
.wip-status-row.filtering .wip-status-card:not(.active) {
opacity: 0.5;
}
/* Content Grid */
.content-grid {
display: grid;
grid-template-columns: 3fr 1fr;
gap: 16px;
}
/* Card Styles */
.card {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
.card-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: #fafbfc;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.card-body {
padding: 16px;
overflow-x: auto;
}
.card-body.matrix-container {
padding-left: 0;
padding-top: 0;
padding-bottom: 0;
}
/* Matrix Table */
.matrix-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.matrix-table th,
.matrix-table td {
padding: 8px 10px;
text-align: right;
border: 1px solid #e5e7eb;
white-space: nowrap;
}
.matrix-table th {
background: #f3f4f6;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
border-bottom: 2px solid #cbd5e1;
}
.matrix-table th:first-child {
text-align: left;
position: sticky;
left: 0;
top: 0;
z-index: 3;
background: #e5e7eb;
border-right: 2px solid #cbd5e1;
border-bottom: 2px solid #cbd5e1;
}
.matrix-table td:first-child {
text-align: left;
font-weight: 600;
position: sticky;
left: 0;
background: #f9fafb;
z-index: 1;
border-right: 2px solid #cbd5e1;
}
.matrix-table tbody tr:hover td {
background: #f0f4ff;
}
.matrix-table tbody tr:hover td:first-child {
background: #e8ecff;
}
.matrix-table .total-row td {
background: #e5e7eb;
font-weight: bold;
}
.matrix-table .total-row td:first-child {
border-right: 2px solid #cbd5e1;
}
.matrix-table .total-col {
background: #e5e7eb;
font-weight: bold;
}
.matrix-table .clickable {
cursor: pointer;
color: var(--primary);
}
.matrix-table .clickable:hover {
text-decoration: underline;
}
/* Hold Table */
.hold-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.hold-table th,
.hold-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.hold-table th {
background: #f3f4f6;
font-weight: 600;
}
.hold-table td:nth-child(2),
.hold-table td:nth-child(3) {
text-align: right;
}
.hold-table th:nth-child(2),
.hold-table th:nth-child(3) {
text-align: right;
}
.hold-table tbody tr:hover {
background: #f9fafb;
}
/* Hold Type Badge */
.hold-type-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
margin-right: 6px;
}
.hold-type-badge.quality {
background: #FEE2E2;
color: #991B1B;
}
.hold-reason-link {
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.hold-reason-link:hover {
text-decoration: underline;
color: var(--primary-dark);
}
.hold-type-badge.non-quality {
background: #FFEDD5;
color: #9A3412;
}
/* Loading */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.loading-spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 10px;
}
.placeholder {
text-align: center;
padding: 40px 20px;
color: var(--muted);
}
/* Responsive */
@media (max-width: 1400px) {
.wip-status-row {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 1200px) {
.content-grid {
grid-template-columns: 1fr;
}
.summary-row {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.summary-row {
grid-template-columns: 1fr;
}
.wip-status-row {
grid-template-columns: 1fr;
}
.filters {
flex-direction: column;
align-items: stretch;
}
.filter-group input {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard">
<!-- Header -->
<div class="header">
<h1>WIP Overview Dashboard</h1>
<div class="header-right">
<span class="last-update">
<span class="refresh-indicator" id="refreshIndicator"></span>
<span class="refresh-success" id="refreshSuccess">&#10003;</span>
<span class="refresh-error" id="refreshError"></span>
<span id="lastUpdate"></span>
</span>
<button class="btn btn-light" onclick="manualRefresh()">
<span id="refreshBtnText">重新整理</span>
</button>
</div>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label>Work Order</label>
<input type="text" id="filterWorkorder" placeholder="輸入 Work Order..." autocomplete="off">
<span class="search-loading" id="workorderLoading"></span>
<div class="autocomplete-dropdown" id="workorderDropdown"></div>
</div>
<div class="filter-group">
<label>Lot ID</label>
<input type="text" id="filterLotid" placeholder="輸入 Lot ID..." autocomplete="off">
<span class="search-loading" id="lotidLoading"></span>
<div class="autocomplete-dropdown" id="lotidDropdown"></div>
</div>
<button class="btn-primary" onclick="applyFilters()">套用篩選</button>
<button class="btn-secondary" onclick="clearFilters()">清除篩選</button>
<span id="activeFilters"></span>
</div>
<!-- Summary Cards -->
<div class="summary-row">
<div class="summary-card">
<div class="summary-label">Total Lots</div>
<div class="summary-value" id="totalLots">-</div>
</div>
<div class="summary-card">
<div class="summary-label">Total QTY</div>
<div class="summary-value" id="totalQty">-</div>
</div>
</div>
<!-- WIP Status Cards -->
<div class="wip-status-row">
<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" 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 quality-hold" onclick="toggleStatusFilter('quality-hold')">
<div class="status-header"><span class="dot"></span>品質異常</div>
<div class="status-values">
<span id="qualityHoldLots">-</span>
<span id="qualityHoldQty">-</span>
</div>
</div>
<div class="wip-status-card non-quality-hold" onclick="toggleStatusFilter('non-quality-hold')">
<div class="status-header"><span class="dot"></span>非品質異常</div>
<div class="status-values">
<span id="nonQualityHoldLots">-</span>
<span id="nonQualityHoldQty">-</span>
</div>
</div>
</div>
<!-- Content Grid -->
<div class="content-grid">
<!-- Matrix Table -->
<div class="card">
<div class="card-header">
<div class="card-title">Workcenter x Package Matrix (QTY)</div>
</div>
<div class="card-body matrix-container" style="max-height: 500px; overflow: auto;">
<div id="matrixContainer">
<div class="placeholder">Loading...</div>
</div>
</div>
</div>
<!-- Hold Summary -->
<div class="card">
<div class="card-header">
<div class="card-title">Hold Summary</div>
</div>
<div class="card-body" style="max-height: 500px; overflow: auto;">
<div id="holdContainer">
<div class="placeholder">Loading...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay">
<span class="loading-spinner"></span>
<span>Loading...</span>
</div>
{% endblock %}
{% block scripts %}
<script>
// ============================================================
// State Management
// ============================================================
const state = {
summary: null,
matrix: null,
hold: null,
isLoading: false,
lastError: false,
refreshTimer: null,
REFRESH_INTERVAL: 10 * 60 * 1000, // 10 minutes
filters: {
workorder: '',
lotid: ''
},
searchTimers: {
workorder: null,
lotid: null
}
};
// Status filter state (null = no filter, 'run'/'queue'/'hold' = filtered)
let activeStatusFilter = null;
// AbortController for cancelling in-flight requests
let matrixAbortController = null; // For loadMatrixOnly()
let loadAllAbortController = null; // For loadAllData()
// ============================================================
// Utility Functions
// ============================================================
function formatNumber(num) {
if (num === null || num === undefined || num === '-') return '-';
return num.toLocaleString('zh-TW');
}
function updateElementWithTransition(elementId, newValue) {
const el = document.getElementById(elementId);
const oldValue = el.textContent;
let formattedNew;
if (typeof newValue === 'number') {
formattedNew = formatNumber(newValue);
} else if (newValue === null || newValue === undefined) {
formattedNew = '-';
} else {
formattedNew = newValue;
}
if (oldValue !== formattedNew) {
el.textContent = formattedNew;
el.classList.add('updated');
setTimeout(() => el.classList.remove('updated'), 500);
}
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
function buildQueryParams() {
const params = {};
if (state.filters.workorder) {
params.workorder = state.filters.workorder;
}
if (state.filters.lotid) {
params.lotid = state.filters.lotid;
}
return params;
}
// ============================================================
// API Functions (using MesApi)
// ============================================================
const API_TIMEOUT = 60000; // 60 seconds timeout
async function fetchSummary(signal = null) {
const params = buildQueryParams();
const result = await MesApi.get('/api/wip/overview/summary', {
params,
timeout: API_TIMEOUT,
signal
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch summary');
}
async function fetchMatrix(signal = null) {
const params = buildQueryParams();
// Add status filter if active
if (activeStatusFilter) {
if (activeStatusFilter === 'quality-hold') {
params.status = 'HOLD';
params.hold_type = 'quality';
} else if (activeStatusFilter === 'non-quality-hold') {
params.status = 'HOLD';
params.hold_type = 'non-quality';
} else {
params.status = activeStatusFilter.toUpperCase();
}
}
const result = await MesApi.get('/api/wip/overview/matrix', {
params,
timeout: API_TIMEOUT,
signal
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch matrix');
}
async function fetchHold(signal = null) {
const params = buildQueryParams();
const result = await MesApi.get('/api/wip/overview/hold', {
params,
timeout: API_TIMEOUT,
signal
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch hold');
}
// ============================================================
// Autocomplete Functions
// ============================================================
async function searchAutocomplete(type, query) {
if (query.length < 2) {
return [];
}
const loadingEl = document.getElementById(`${type}Loading`);
loadingEl.classList.add('active');
try {
const result = await MesApi.get('/api/wip/meta/search', {
params: { type, q: query, limit: 20 },
silent: true,
retries: 0 // No retry for autocomplete
});
if (result.success) {
return result.data.items || [];
}
} catch (error) {
console.error(`Search ${type} failed:`, error);
} finally {
loadingEl.classList.remove('active');
}
return [];
}
function showDropdown(type, items) {
const dropdown = document.getElementById(`${type}Dropdown`);
if (items.length === 0) {
dropdown.innerHTML = '<div class="autocomplete-item no-results">無符合結果</div>';
} else {
dropdown.innerHTML = items.map(item =>
`<div class="autocomplete-item" onclick="selectAutocomplete('${type}', '${item}')">${item}</div>`
).join('');
}
dropdown.classList.add('active');
}
function hideDropdown(type) {
const dropdown = document.getElementById(`${type}Dropdown`);
dropdown.classList.remove('active');
}
function selectAutocomplete(type, value) {
const input = document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`);
input.value = value;
hideDropdown(type);
}
// Setup autocomplete for inputs
function setupAutocomplete(type) {
const input = document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`);
const debouncedSearch = debounce(async (query) => {
if (query.length >= 2) {
const items = await searchAutocomplete(type, query);
showDropdown(type, items);
} else {
hideDropdown(type);
}
}, 300);
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
input.addEventListener('focus', async () => {
const query = input.value;
if (query.length >= 2) {
const items = await searchAutocomplete(type, query);
showDropdown(type, items);
}
});
input.addEventListener('blur', () => {
// Delay hide to allow click on dropdown
setTimeout(() => hideDropdown(type), 200);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
hideDropdown(type);
applyFilters();
}
});
}
// ============================================================
// Filter Functions
// ============================================================
function applyFilters() {
state.filters.workorder = document.getElementById('filterWorkorder').value.trim();
state.filters.lotid = document.getElementById('filterLotid').value.trim();
updateActiveFiltersDisplay();
loadAllData(false);
}
function clearFilters() {
document.getElementById('filterWorkorder').value = '';
document.getElementById('filterLotid').value = '';
state.filters.workorder = '';
state.filters.lotid = '';
updateActiveFiltersDisplay();
loadAllData(false);
}
function removeFilter(type) {
document.getElementById(`filter${type.charAt(0).toUpperCase() + type.slice(1)}`).value = '';
state.filters[type] = '';
updateActiveFiltersDisplay();
loadAllData(false);
}
function updateActiveFiltersDisplay() {
const container = document.getElementById('activeFilters');
let html = '';
if (state.filters.workorder) {
html += `<span class="filter-tag">WO: ${state.filters.workorder} <span class="remove" onclick="removeFilter('workorder')">×</span></span>`;
}
if (state.filters.lotid) {
html += `<span class="filter-tag">Lot: ${state.filters.lotid} <span class="remove" onclick="removeFilter('lotid')">×</span></span>`;
}
container.innerHTML = html;
}
// ============================================================
// Render Functions
// ============================================================
function renderSummary(data) {
if (!data) return;
updateElementWithTransition('totalLots', data.totalLots);
updateElementWithTransition('totalQty', data.totalQtyPcs);
const ws = data.byWipStatus || {};
const runLots = ws.run?.lots;
const runQty = ws.run?.qtyPcs;
const queueLots = ws.queue?.lots;
const queueQty = ws.queue?.qtyPcs;
const qualityHoldLots = ws.qualityHold?.lots;
const qualityHoldQty = ws.qualityHold?.qtyPcs;
const nonQualityHoldLots = ws.nonQualityHold?.lots;
const nonQualityHoldQty = ws.nonQualityHold?.qtyPcs;
updateElementWithTransition(
'runLots',
runLots === null || runLots === undefined ? '-' : `${formatNumber(runLots)} lots`
);
updateElementWithTransition(
'runQty',
runQty === null || runQty === undefined ? '-' : formatNumber(runQty)
);
updateElementWithTransition(
'queueLots',
queueLots === null || queueLots === undefined ? '-' : `${formatNumber(queueLots)} lots`
);
updateElementWithTransition(
'queueQty',
queueQty === null || queueQty === undefined ? '-' : formatNumber(queueQty)
);
updateElementWithTransition(
'qualityHoldLots',
qualityHoldLots === null || qualityHoldLots === undefined ? '-' : `${formatNumber(qualityHoldLots)} lots`
);
updateElementWithTransition(
'qualityHoldQty',
qualityHoldQty === null || qualityHoldQty === undefined ? '-' : formatNumber(qualityHoldQty)
);
updateElementWithTransition(
'nonQualityHoldLots',
nonQualityHoldLots === null || nonQualityHoldLots === undefined ? '-' : `${formatNumber(nonQualityHoldLots)} lots`
);
updateElementWithTransition(
'nonQualityHoldQty',
nonQualityHoldQty === null || nonQualityHoldQty === undefined ? '-' : formatNumber(nonQualityHoldQty)
);
if (data.dataUpdateDate) {
document.getElementById('lastUpdate').textContent = `Last Update: ${data.dataUpdateDate}`;
}
}
// ============================================================
// Status Filter Functions
// ============================================================
function toggleStatusFilter(status) {
if (activeStatusFilter === status) {
// Deactivate filter
activeStatusFilter = null;
} else {
// Activate new filter
activeStatusFilter = status;
}
updateCardStyles();
updateMatrixTitle();
loadMatrixOnly();
}
function updateCardStyles() {
const row = document.querySelector('.wip-status-row');
document.querySelectorAll('.wip-status-card').forEach(card => {
card.classList.remove('active');
});
if (activeStatusFilter) {
row.classList.add('filtering');
const activeCard = document.querySelector(`.wip-status-card.${activeStatusFilter}`);
if (activeCard) {
activeCard.classList.add('active');
}
} else {
row.classList.remove('filtering');
}
}
function updateMatrixTitle() {
const titleEl = document.querySelector('.card-title');
if (!titleEl) return;
const baseTitle = 'Workcenter x Package Matrix (QTY)';
if (activeStatusFilter) {
let statusLabel;
if (activeStatusFilter === 'quality-hold') {
statusLabel = '品質異常 Hold';
} else if (activeStatusFilter === 'non-quality-hold') {
statusLabel = '非品質異常 Hold';
} else {
statusLabel = activeStatusFilter.toUpperCase();
}
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
} else {
titleEl.textContent = baseTitle;
}
}
async function loadMatrixOnly() {
// Cancel any in-flight matrix request to prevent pile-up
if (matrixAbortController) {
matrixAbortController.abort();
}
matrixAbortController = new AbortController();
const container = document.getElementById('matrixContainer');
container.innerHTML = '<div class="placeholder">Loading...</div>';
try {
const matrix = await fetchMatrix(matrixAbortController.signal);
state.matrix = matrix;
renderMatrix(matrix);
} catch (error) {
// Ignore abort errors (expected when user clicks quickly)
if (error.name === 'AbortError') {
console.log('[WIP Overview] Matrix request cancelled (new filter selected)');
return;
}
console.error('[WIP Overview] Matrix load failed:', error);
container.innerHTML = '<div class="placeholder">Error loading data</div>';
}
}
function renderMatrix(data) {
const container = document.getElementById('matrixContainer');
if (!data || !data.workcenters || data.workcenters.length === 0) {
container.innerHTML = '<div class="placeholder">No data available</div>';
return;
}
// Limit packages to top 15 for display
const displayPackages = data.packages.slice(0, 15);
let html = '<table class="matrix-table"><thead><tr>';
html += '<th>Workcenter</th>';
displayPackages.forEach(pkg => {
html += `<th>${pkg}</th>`;
});
html += '<th class="total-col">Total</th>';
html += '</tr></thead><tbody>';
// Data rows
data.workcenters.forEach(wc => {
html += '<tr>';
html += `<td class="clickable" onclick="navigateToDetail('${wc.replace(/'/g, "\\'")}')">${wc}</td>`;
displayPackages.forEach(pkg => {
const qty = data.matrix[wc]?.[pkg] || 0;
html += `<td>${qty ? formatNumber(qty) : '-'}</td>`;
});
html += `<td class="total-col">${formatNumber(data.workcenter_totals[wc] || 0)}</td>`;
html += '</tr>';
});
// Total row
html += '<tr class="total-row">';
html += '<td>Total</td>';
displayPackages.forEach(pkg => {
html += `<td>${formatNumber(data.package_totals[pkg] || 0)}</td>`;
});
html += `<td class="total-col">${formatNumber(data.grand_total || 0)}</td>`;
html += '</tr>';
html += '</tbody></table>';
container.innerHTML = html;
}
function renderHold(data) {
const container = document.getElementById('holdContainer');
if (!data || !data.items || data.items.length === 0) {
container.innerHTML = '<div class="placeholder">No hold lots</div>';
return;
}
let html = '<table class="hold-table"><thead><tr>';
html += '<th>Hold Reason</th>';
html += '<th>Lots</th>';
html += '<th>QTY</th>';
html += '</tr></thead><tbody>';
data.items.forEach(item => {
const badgeClass = item.holdType === 'quality' ? 'quality' : 'non-quality';
const badgeText = item.holdType === 'quality' ? '品質' : '非品質';
const reasonText = item.reason || '-';
const reasonLink = item.reason
? `<a href="/hold-detail?reason=${encodeURIComponent(item.reason)}" class="hold-reason-link">${reasonText}</a>`
: reasonText;
html += '<tr>';
html += `<td><span class="hold-type-badge ${badgeClass}">${badgeText}</span>${reasonLink}</td>`;
html += `<td>${formatNumber(item.lots)}</td>`;
html += `<td>${formatNumber(item.qty)}</td>`;
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ============================================================
// Navigation
// ============================================================
function navigateToDetail(workcenter) {
const params = new URLSearchParams();
params.append('workcenter', workcenter);
if (state.filters.workorder) params.append('workorder', state.filters.workorder);
if (state.filters.lotid) params.append('lotid', state.filters.lotid);
window.location.href = `/wip-detail?${params.toString()}`;
}
// ============================================================
// Data Loading
// ============================================================
async function loadAllData(showOverlay = true) {
// Cancel any in-flight request to prevent connection pile-up
if (loadAllAbortController) {
loadAllAbortController.abort();
console.log('[WIP Overview] Previous request cancelled');
}
loadAllAbortController = new AbortController();
const signal = loadAllAbortController.signal;
state.isLoading = true;
console.log('[WIP Overview] Loading data...', showOverlay ? '(with overlay)' : '(background)');
if (showOverlay) {
document.getElementById('loadingOverlay').style.display = 'flex';
}
// Show refresh indicator
document.getElementById('refreshIndicator').classList.add('active');
document.getElementById('refreshError').classList.remove('active');
document.getElementById('refreshSuccess').classList.remove('active');
try {
const startTime = performance.now();
const [summary, matrix, hold] = await Promise.all([
fetchSummary(signal),
fetchMatrix(signal),
fetchHold(signal)
]);
const elapsed = Math.round(performance.now() - startTime);
state.summary = summary;
state.matrix = matrix;
state.hold = hold;
state.lastError = false;
renderSummary(summary);
renderMatrix(matrix);
renderHold(hold);
console.log(`[WIP Overview] Data loaded successfully in ${elapsed}ms`);
// Show success indicator
document.getElementById('refreshSuccess').classList.add('active');
setTimeout(() => {
document.getElementById('refreshSuccess').classList.remove('active');
}, 1500);
} catch (error) {
// Ignore abort errors (expected when user triggers new request)
if (error.name === 'AbortError') {
console.log('[WIP Overview] Request cancelled (new request started)');
return;
}
console.error('[WIP Overview] Data load failed:', error);
state.lastError = true;
document.getElementById('refreshError').classList.add('active');
} finally {
state.isLoading = false;
document.getElementById('loadingOverlay').style.display = 'none';
document.getElementById('refreshIndicator').classList.remove('active');
}
}
// ============================================================
// Auto-refresh
// ============================================================
function startAutoRefresh() {
if (state.refreshTimer) {
clearInterval(state.refreshTimer);
}
console.log('[WIP Overview] Auto-refresh started, interval:', state.REFRESH_INTERVAL / 1000, 'seconds');
state.refreshTimer = setInterval(() => {
if (!document.hidden) {
console.log('[WIP Overview] Auto-refresh triggered at', new Date().toLocaleTimeString());
loadAllData(false); // Don't show overlay for auto-refresh
} else {
console.log('[WIP Overview] Auto-refresh skipped (tab hidden)');
}
}, state.REFRESH_INTERVAL);
}
function manualRefresh() {
// Reset timer on manual refresh
startAutoRefresh();
loadAllData(false);
}
// Handle page visibility
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// Page became visible - refresh immediately
loadAllData(false);
startAutoRefresh();
}
});
// ============================================================
// Initialize
// ============================================================
window.onload = function() {
setupAutocomplete('workorder');
setupAutocomplete('lotid');
loadAllData(true);
startAutoRefresh();
};
</script>
{% endblock %}