Files
DashBoard/src/mes_dashboard/templates/wip_detail.html
2026-02-08 08:30:48 +08:00

1795 lines
62 KiB
HTML

{% extends "_base.html" %}
{% block title %}WIP Detail 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-left {
display: flex;
align-items: center;
gap: 16px;
}
.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);
}
.btn-back {
background: rgba(255,255,255,0.15);
color: white;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-back:hover {
background: rgba(255,255,255,0.25);
}
/* 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;
}
.filter-group label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.filter-group select,
.filter-group input[type="text"] {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
min-width: 150px;
}
.filter-group select:focus,
.filter-group input[type="text"]:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
/* Autocomplete container */
.autocomplete-container {
position: relative;
display: inline-block;
}
.autocomplete-container input {
width: 180px;
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: white;
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
box-shadow: var(--shadow);
z-index: 100;
display: none;
}
.autocomplete-dropdown.show {
display: block;
}
.autocomplete-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.autocomplete-item:hover {
background: #f0f2ff;
}
.autocomplete-loading {
padding: 8px 12px;
color: var(--muted);
font-size: 12px;
text-align: center;
}
.autocomplete-empty {
padding: 8px 12px;
color: var(--muted);
font-size: 12px;
text-align: center;
}
.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;
}
/* Summary Cards */
.summary-row {
display: grid;
grid-template-columns: repeat(5, 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.success {
color: var(--success);
}
.summary-value.warning {
color: var(--warning);
}
.summary-value.updated {
animation: valueUpdate 0.5s ease;
}
/* Status Card Colors - Clickable */
.summary-card.status-run,
.summary-card.status-queue,
.summary-card.status-quality-hold,
.summary-card.status-non-quality-hold {
cursor: pointer;
transition: all 0.2s ease;
}
.summary-card.status-run:hover,
.summary-card.status-queue:hover,
.summary-card.status-quality-hold:hover,
.summary-card.status-non-quality-hold:hover {
transform: translateY(-2px);
}
.summary-card.status-run {
background: #F0FDF4;
border-color: #22C55E;
}
.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;
border-color: #F59E0B;
}
.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-quality-hold {
background: #FEF2F2;
border-color: #EF4444;
}
.summary-card.status-quality-hold .summary-value {
color: #991B1B;
}
.summary-card.status-quality-hold:hover {
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.25);
}
.summary-card.status-quality-hold.active {
background: #FEE2E2;
border-width: 3px;
transform: scale(1.03);
box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
}
.summary-card.status-non-quality-hold {
background: #FFF7ED;
border-color: #F97316;
}
.summary-card.status-non-quality-hold .summary-value {
color: #9A3412;
}
.summary-card.status-non-quality-hold:hover {
box-shadow: 0 4px 15px rgba(249, 115, 22, 0.25);
}
.summary-card.status-non-quality-hold.active {
background: #FFEDD5;
border-width: 3px;
transform: scale(1.03);
box-shadow: 0 6px 25px rgba(249, 115, 22, 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-quality-hold:not(.active),
.summary-row.filtering .summary-card.status-non-quality-hold:not(.active) {
opacity: 0.5;
}
@keyframes valueUpdate {
0% { transform: scale(1); }
50% { transform: scale(1.05); background: rgba(102, 126, 234, 0.1); }
100% { transform: scale(1); }
}
/* Table Section */
.table-section {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: #fafbfc;
}
.table-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.table-info {
font-size: 13px;
color: var(--muted);
}
.table-container {
overflow-x: auto;
overflow-y: auto;
max-height: 600px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead {
position: sticky;
top: 0;
z-index: 10;
}
th {
background: #f8f9fa;
padding: 10px 12px;
text-align: left;
border-bottom: 2px solid var(--border);
font-weight: 600;
white-space: nowrap;
}
/* Fixed columns */
th.fixed-col,
td.fixed-col {
position: sticky;
background: #fff;
z-index: 5;
}
th.fixed-col {
background: #f8f9fa;
z-index: 11;
}
th.fixed-col:nth-child(1), td.fixed-col:nth-child(1) { left: 0; min-width: 150px; }
th.fixed-col:nth-child(2), td.fixed-col:nth-child(2) { left: 150px; min-width: 100px; }
th.fixed-col:nth-child(3), td.fixed-col:nth-child(3) { left: 250px; min-width: 120px; }
th.fixed-col:nth-child(4), td.fixed-col:nth-child(4) { left: 370px; min-width: 100px; border-right: 2px solid var(--primary); }
td {
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
white-space: nowrap;
}
tbody tr:hover td {
background: #f8f9fc;
}
tbody tr:hover td.fixed-col {
background: #f0f2ff;
}
/* Spec columns */
th.spec-col {
background: #e8ebff;
text-align: center;
font-size: 12px;
min-width: 80px;
}
td.spec-cell {
text-align: center;
font-weight: 600;
}
td.spec-cell.has-data {
background: #d4edda;
color: #155724;
}
/* WIP Status styles in table */
.wip-status-run {
color: #166534;
background: #F0FDF4;
font-weight: 600;
}
.wip-status-queue {
color: #92400E;
background: #FFFBEB;
font-weight: 600;
}
.wip-status-hold {
color: #991B1B;
background: #FEF2F2;
font-weight: 600;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 16px;
border-top: 1px solid var(--border);
}
.pagination button {
padding: 8px 16px;
border: 1px solid var(--border);
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.pagination button:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .page-info {
font-size: 13px;
color: var(--muted);
}
/* Loading */
.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: 60px 20px;
color: var(--muted);
}
/* Lot Detail Panel */
.lot-detail-panel {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
margin-top: 16px;
overflow: hidden;
display: none;
}
.lot-detail-panel.show {
display: block;
}
.lot-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.lot-detail-title {
font-size: 16px;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.lot-detail-title .lot-id {
background: rgba(255,255,255,0.2);
padding: 4px 12px;
border-radius: 6px;
font-family: monospace;
}
.lot-detail-close {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.lot-detail-close:hover {
background: rgba(255,255,255,0.3);
}
.lot-detail-content {
padding: 20px;
}
.lot-detail-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.lot-detail-section {
background: #f8f9fa;
border-radius: 8px;
padding: 14px;
}
.lot-detail-section-title {
font-size: 13px;
font-weight: 600;
color: var(--primary);
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 2px solid var(--primary);
}
.lot-detail-field {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
.lot-detail-field:last-child {
margin-bottom: 0;
}
.lot-detail-label {
font-size: 11px;
color: var(--muted);
margin-bottom: 2px;
}
.lot-detail-value {
font-size: 13px;
color: var(--text);
word-break: break-word;
}
.lot-detail-value.empty {
color: #ccc;
}
.lot-detail-value.status-run {
color: #166534;
font-weight: 600;
}
.lot-detail-value.status-queue {
color: #92400E;
font-weight: 600;
}
.lot-detail-value.status-hold {
color: #991B1B;
font-weight: 600;
}
.lot-detail-loading {
text-align: center;
padding: 40px;
color: var(--muted);
}
.lot-detail-loading .loading-spinner {
margin-right: 8px;
}
/* Clickable LOT ID in table */
.lot-id-link {
color: var(--primary);
cursor: pointer;
text-decoration: none;
transition: color 0.2s;
}
.lot-id-link:hover {
color: var(--primary-dark);
text-decoration: underline;
}
.lot-id-link.active {
background: rgba(102, 126, 234, 0.15);
padding: 2px 6px;
border-radius: 4px;
}
/* Responsive */
@media (max-width: 1400px) {
.summary-row {
grid-template-columns: repeat(3, 1fr);
}
.lot-detail-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 1000px) {
.summary-row {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filters {
flex-direction: column;
align-items: stretch;
}
.filter-group select {
width: 100%;
}
.summary-row {
grid-template-columns: 1fr;
}
.lot-detail-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard">
<!-- Header -->
<div class="header">
<div class="header-left">
<a href="/wip-overview" class="btn btn-back">&larr; Overview</a>
<h1 id="pageTitle">WIP Detail</h1>
</div>
<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()">Refresh</button>
</div>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label>WORKORDER</label>
<div class="autocomplete-container">
<input type="text" id="filterWorkorder" placeholder="Search..." autocomplete="off">
<div class="autocomplete-dropdown" id="workorderDropdown"></div>
</div>
</div>
<div class="filter-group">
<label>LOT ID</label>
<div class="autocomplete-container">
<input type="text" id="filterLotid" placeholder="Search..." autocomplete="off">
<div class="autocomplete-dropdown" id="lotidDropdown"></div>
</div>
</div>
<div class="filter-group">
<label>PACKAGE</label>
<div class="autocomplete-container">
<input type="text" id="filterPackage" placeholder="Search..." autocomplete="off">
<div class="autocomplete-dropdown" id="packageDropdown"></div>
</div>
</div>
<div class="filter-group">
<label>TYPE</label>
<div class="autocomplete-container">
<input type="text" id="filterType" placeholder="Search..." autocomplete="off">
<div class="autocomplete-dropdown" id="typeDropdown"></div>
</div>
</div>
<button class="btn-primary" onclick="applyFilters()">Apply</button>
<button class="btn-secondary" onclick="clearFilters()">Clear</button>
</div>
<!-- Summary Cards -->
<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" 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" 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-quality-hold" onclick="toggleStatusFilter('quality-hold')" title="Click to filter Quality Hold only">
<div class="summary-label">品質異常</div>
<div class="summary-value" id="qualityHoldLots">-</div>
</div>
<div class="summary-card status-non-quality-hold" onclick="toggleStatusFilter('non-quality-hold')" title="Click to filter Non-Quality Hold only">
<div class="summary-label">非品質異常</div>
<div class="summary-value" id="nonQualityHoldLots">-</div>
</div>
</div>
<!-- Table Section -->
<div class="table-section" style="position: relative;">
<div class="table-header">
<div class="table-title">Lot Details</div>
<div class="table-info" id="tableInfo">Loading...</div>
</div>
<div class="table-container" id="tableContainer">
<div class="placeholder">Loading...</div>
</div>
<div class="pagination" id="pagination" style="display: none;">
<button id="btnPrev" onclick="prevPage()">Prev</button>
<span class="page-info" id="pageInfo">Page 1</span>
<button id="btnNext" onclick="nextPage()">Next</button>
</div>
</div>
<!-- Lot Detail Panel -->
<div class="lot-detail-panel" id="lotDetailPanel">
<div class="lot-detail-header">
<div class="lot-detail-title">
Lot Detail - <span class="lot-id" id="lotDetailLotId"></span>
</div>
<button class="lot-detail-close" onclick="closeLotDetail()">Close</button>
</div>
<div class="lot-detail-content" id="lotDetailContent">
<div class="lot-detail-loading">
<span class="loading-spinner"></span>Loading...
</div>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay">
<span class="loading-spinner"></span>
<span>Loading...</span>
</div>
{% endblock %}
{% block scripts %}
{% set wip_detail_js = frontend_asset('wip-detail.js') %}
{% if wip_detail_js %}
<script type="module" src="{{ wip_detail_js }}"></script>
{% else %}
<script>
// ============================================================
// State Management
// ============================================================
const state = {
workcenter: '',
data: null,
packages: [],
page: 1,
pageSize: 100,
filters: {
package: '',
type: '',
workorder: '',
lotid: ''
},
isLoading: false,
refreshTimer: null,
REFRESH_INTERVAL: 10 * 60 * 1000, // 10 minutes
searchDebounceTimers: {}
};
// WIP Status filter (separate from other filters)
let activeStatusFilter = null; // null | 'run' | 'queue' | 'quality-hold' | 'non-quality-hold'
// AbortController for cancelling in-flight requests
let tableAbortController = null; // For loadTableOnly()
let loadAllAbortController = null; // For loadAllData()
// ============================================================
// Utility Functions
// ============================================================
function formatNumber(num) {
if (num === null || num === undefined || num === '-') return '-';
return num.toLocaleString('zh-TW');
}
function updateElementWithTransition(elementId, newValue) {
const el = document.getElementById(elementId);
const oldValue = el.textContent;
const formattedNew = formatNumber(newValue);
if (oldValue !== formattedNew) {
el.textContent = formattedNew;
el.classList.add('updated');
setTimeout(() => el.classList.remove('updated'), 500);
}
}
function getUrlParam(name) {
const params = new URLSearchParams(window.location.search);
return params.get(name) || '';
}
// ============================================================
// API Functions (using MesApi)
// ============================================================
const API_TIMEOUT = 60000; // 60 seconds timeout
async function fetchPackages() {
const result = await MesApi.get('/api/wip/meta/packages', { silent: true, timeout: API_TIMEOUT });
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch packages');
}
async function fetchDetail(signal = null) {
const params = {
page: state.page,
page_size: state.pageSize
};
if (state.filters.package) {
params.package = state.filters.package;
}
if (state.filters.type) {
params.type = state.filters.type;
}
if (activeStatusFilter) {
// Handle hold type filters
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 {
// Convert to API status format (RUN/QUEUE)
params.status = activeStatusFilter.toUpperCase();
}
}
if (state.filters.workorder) {
params.workorder = state.filters.workorder;
}
if (state.filters.lotid) {
params.lotid = state.filters.lotid;
}
const result = await MesApi.get(`/api/wip/detail/${encodeURIComponent(state.workcenter)}`, {
params,
timeout: API_TIMEOUT,
signal
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch detail');
}
async function fetchWorkcenters() {
const result = await MesApi.get('/api/wip/meta/workcenters', { silent: true, timeout: API_TIMEOUT });
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch workcenters');
}
async function searchApi(type, query) {
if (!query || query.length < 2) {
return [];
}
// Map internal type to API field name
const fieldMap = {
'workorder': 'workorder',
'lotid': 'lotid',
'package': 'package',
'type': 'pj_type'
};
const field = fieldMap[type] || type;
// Build cross-filter parameters (exclude current field being searched)
// Get current input values for cross-filtering
const currentWorkorder = document.getElementById('filterWorkorder').value.trim();
const currentLotid = document.getElementById('filterLotid').value.trim();
const currentPackage = document.getElementById('filterPackage').value.trim();
const currentType = document.getElementById('filterType').value.trim();
const params = { field, q: query, limit: 20 };
// Add cross-filters (exclude the field being searched)
if (type !== 'workorder' && currentWorkorder) {
params.workorder = currentWorkorder;
}
if (type !== 'lotid' && currentLotid) {
params.lotid = currentLotid;
}
if (type !== 'package' && currentPackage) {
params.package = currentPackage;
}
if (type !== 'type' && currentType) {
params.type = currentType;
}
try {
const result = await MesApi.get('/api/wip/meta/search', {
params,
silent: true,
retries: 0 // No retry for autocomplete
});
if (result.success) {
return result.data.items || [];
}
} catch (e) {
// Ignore errors for autocomplete
}
return [];
}
// ============================================================
// Render Functions
// ============================================================
function renderSummary(summary) {
if (!summary) return;
updateElementWithTransition('totalLots', summary.totalLots);
updateElementWithTransition('runLots', summary.runLots);
updateElementWithTransition('queueLots', summary.queueLots);
updateElementWithTransition('qualityHoldLots', summary.qualityHoldLots);
updateElementWithTransition('nonQualityHoldLots', summary.nonQualityHoldLots);
}
function renderTable(data) {
const container = document.getElementById('tableContainer');
if (!data || !data.lots || data.lots.length === 0) {
container.innerHTML = '<div class="placeholder">No data available</div>';
document.getElementById('tableInfo').textContent = 'No data';
document.getElementById('pagination').style.display = 'none';
return;
}
const specs = data.specs || [];
let html = '<table><thead><tr>';
// Fixed columns
html += '<th class="fixed-col">LOT ID</th>';
html += '<th class="fixed-col">Equipment</th>';
html += '<th class="fixed-col">WIP Status</th>';
html += '<th class="fixed-col">Package</th>';
// Spec columns
specs.forEach(spec => {
html += `<th class="spec-col">${spec}</th>`;
});
html += '</tr></thead><tbody>';
data.lots.forEach(lot => {
html += '<tr>';
// Fixed columns - LOT ID is clickable
const lotIdDisplay = lot.lotId
? `<span class="lot-id-link" onclick="showLotDetail('${lot.lotId}')">${lot.lotId}</span>`
: '-';
html += `<td class="fixed-col">${lotIdDisplay}</td>`;
html += `<td class="fixed-col">${lot.equipment || '<span style="color: var(--muted);">-</span>'}</td>`;
// WIP Status with color and hold reason
const statusClass = `wip-status-${(lot.wipStatus || 'queue').toLowerCase()}`;
let statusText = lot.wipStatus || 'QUEUE';
if (lot.wipStatus === 'HOLD' && lot.holdReason) {
statusText = `HOLD (${lot.holdReason})`;
}
html += `<td class="fixed-col ${statusClass}">${statusText}</td>`;
html += `<td class="fixed-col">${lot.package || '-'}</td>`;
// Spec columns - show QTY in matching spec column
specs.forEach(spec => {
if (lot.spec === spec) {
html += `<td class="spec-cell has-data">${formatNumber(lot.qty)}</td>`;
} else {
html += '<td class="spec-cell"></td>';
}
});
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
// Update info
const pagination = data.pagination;
const start = (pagination.page - 1) * pagination.page_size + 1;
const end = Math.min(pagination.page * pagination.page_size, pagination.total_count);
document.getElementById('tableInfo').textContent =
`Showing ${start} - ${end} of ${formatNumber(pagination.total_count)}`;
// Update pagination
if (pagination.total_pages > 1) {
document.getElementById('pagination').style.display = 'flex';
document.getElementById('pageInfo').textContent =
`Page ${pagination.page} / ${pagination.total_pages}`;
document.getElementById('btnPrev').disabled = pagination.page <= 1;
document.getElementById('btnNext').disabled = pagination.page >= pagination.total_pages;
} else {
document.getElementById('pagination').style.display = 'none';
}
// Update last update time
if (data.sys_date) {
document.getElementById('lastUpdate').textContent = `Last Update: ${data.sys_date}`;
}
}
function populatePackageFilter(packages) {
const select = document.getElementById('filterPackage');
const currentValue = select.value;
select.innerHTML = '<option value="">All</option>';
packages.forEach(pkg => {
const option = document.createElement('option');
option.value = pkg.name;
option.textContent = `${pkg.name} (${pkg.lot_count})`;
select.appendChild(option);
});
select.value = currentValue;
}
// ============================================================
// Data Loading
// ============================================================
async function loadAllData(showOverlay = true) {
// Cancel any in-flight request to prevent connection pile-up
if (loadAllAbortController) {
loadAllAbortController.abort();
console.log('[WIP Detail] Previous request cancelled');
}
loadAllAbortController = new AbortController();
const signal = loadAllAbortController.signal;
state.isLoading = true;
if (showOverlay) {
document.getElementById('loadingOverlay').style.display = 'flex';
}
// Show refresh indicator
document.getElementById('refreshIndicator').classList.add('active');
document.getElementById('refreshError').classList.remove('active');
document.getElementById('refreshSuccess').classList.remove('active');
try {
// Load packages for filter (non-blocking - don't fail if this times out)
if (state.packages.length === 0) {
try {
state.packages = await fetchPackages();
populatePackageFilter(state.packages);
} catch (pkgError) {
console.warn('Failed to load packages filter:', pkgError);
}
}
// Load detail data (main data - this is critical)
state.data = await fetchDetail(signal);
renderSummary(state.data.summary);
renderTable(state.data);
// Show success indicator
document.getElementById('refreshSuccess').classList.add('active');
setTimeout(() => {
document.getElementById('refreshSuccess').classList.remove('active');
}, 1500);
} catch (error) {
// Ignore abort errors (expected when user triggers new request)
if (error.name === 'AbortError') {
console.log('[WIP Detail] Request cancelled (new request started)');
return;
}
console.error('Data load failed:', error);
document.getElementById('refreshError').classList.add('active');
} finally {
state.isLoading = false;
document.getElementById('loadingOverlay').style.display = 'none';
document.getElementById('refreshIndicator').classList.remove('active');
}
}
// ============================================================
// Autocomplete Functions
// ============================================================
function debounce(func, wait, key) {
return function(...args) {
if (state.searchDebounceTimers[key]) {
clearTimeout(state.searchDebounceTimers[key]);
}
state.searchDebounceTimers[key] = setTimeout(() => func.apply(this, args), wait);
};
}
function showDropdown(dropdownId, items, onSelect) {
const dropdown = document.getElementById(dropdownId);
if (!items || items.length === 0) {
dropdown.innerHTML = '<div class="autocomplete-empty">No results</div>';
dropdown.classList.add('show');
return;
}
dropdown.innerHTML = items.map(item =>
`<div class="autocomplete-item" data-value="${item}">${item}</div>`
).join('');
dropdown.classList.add('show');
dropdown.querySelectorAll('.autocomplete-item').forEach(el => {
el.addEventListener('click', () => {
onSelect(el.dataset.value);
dropdown.classList.remove('show');
});
});
}
function hideDropdown(dropdownId) {
document.getElementById(dropdownId).classList.remove('show');
}
function showLoading(dropdownId) {
const dropdown = document.getElementById(dropdownId);
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
dropdown.classList.add('show');
}
function setupAutocomplete(inputId, dropdownId, searchType) {
const input = document.getElementById(inputId);
const doSearch = debounce(async (query) => {
if (query.length < 2) {
hideDropdown(dropdownId);
return;
}
showLoading(dropdownId);
try {
const items = await searchApi(searchType, query);
showDropdown(dropdownId, items, (value) => {
input.value = value;
});
} catch (e) {
hideDropdown(dropdownId);
}
}, 300, inputId);
input.addEventListener('input', (e) => {
doSearch(e.target.value);
});
input.addEventListener('focus', (e) => {
if (e.target.value.length >= 2) {
doSearch(e.target.value);
}
});
// Hide dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest(`#${inputId}`) && !e.target.closest(`#${dropdownId}`)) {
hideDropdown(dropdownId);
}
});
}
// ============================================================
// Status Filter Toggle (Clickable Cards)
// ============================================================
function toggleStatusFilter(status) {
if (activeStatusFilter === status) {
// Clicking the same card again removes the filter
activeStatusFilter = null;
} else {
// Apply new filter
activeStatusFilter = status;
}
// Update card styles
updateCardStyles();
// Update table title
updateTableTitle();
// Reset to page 1 and reload table only (no isLoading guard)
state.page = 1;
loadTableOnly();
}
async function loadTableOnly() {
// Cancel any in-flight request to prevent pile-up
if (tableAbortController) {
tableAbortController.abort();
}
tableAbortController = new AbortController();
// Show loading in table container
const container = document.getElementById('tableContainer');
container.innerHTML = '<div class="placeholder">Loading...</div>';
// Show refresh indicator
document.getElementById('refreshIndicator').classList.add('active');
try {
state.data = await fetchDetail(tableAbortController.signal);
renderSummary(state.data.summary);
renderTable(state.data);
// Show success indicator
document.getElementById('refreshSuccess').classList.add('active');
setTimeout(() => {
document.getElementById('refreshSuccess').classList.remove('active');
}, 1500);
} catch (error) {
// Ignore abort errors (expected when user clicks quickly)
if (error.name === 'AbortError') {
console.log('[WIP Detail] Table request cancelled (new filter selected)');
return;
}
console.error('Table load failed:', error);
container.innerHTML = '<div class="placeholder">Error loading data</div>';
document.getElementById('refreshError').classList.add('active');
} finally {
document.getElementById('refreshIndicator').classList.remove('active');
}
}
function updateCardStyles() {
const row = document.getElementById('summaryRow');
const statusCards = document.querySelectorAll('.summary-card.status-run, .summary-card.status-queue, .summary-card.status-quality-hold, .summary-card.status-non-quality-hold');
// Remove active from all status cards
statusCards.forEach(card => {
card.classList.remove('active');
});
if (activeStatusFilter) {
// Add filtering class to row (dims non-active cards)
row.classList.add('filtering');
// Add active to the selected card
const activeCard = document.querySelector(`.summary-card.status-${activeStatusFilter}`);
if (activeCard) {
activeCard.classList.add('active');
}
} else {
// Remove filtering class
row.classList.remove('filtering');
}
}
function updateTableTitle() {
const titleEl = document.querySelector('.table-title');
const baseTitle = 'Lot Details';
if (activeStatusFilter) {
let statusLabel;
if (activeStatusFilter === 'quality-hold') {
statusLabel = '品質異常 Hold';
} else if (activeStatusFilter === 'non-quality-hold') {
statusLabel = '非品質異常 Hold';
} else {
statusLabel = activeStatusFilter.toUpperCase();
}
titleEl.textContent = `${baseTitle} - ${statusLabel} Only`;
} else {
titleEl.textContent = baseTitle;
}
}
// ============================================================
// Filter & Pagination
// ============================================================
function applyFilters() {
state.filters.workorder = document.getElementById('filterWorkorder').value.trim();
state.filters.lotid = document.getElementById('filterLotid').value.trim();
state.filters.package = document.getElementById('filterPackage').value.trim();
state.filters.type = document.getElementById('filterType').value.trim();
state.page = 1;
loadAllData(false);
}
function clearFilters() {
document.getElementById('filterWorkorder').value = '';
document.getElementById('filterLotid').value = '';
document.getElementById('filterPackage').value = '';
document.getElementById('filterType').value = '';
state.filters = { package: '', type: '', workorder: '', lotid: '' };
// Also clear status filter
activeStatusFilter = null;
updateCardStyles();
updateTableTitle();
state.page = 1;
loadAllData(false);
}
function prevPage() {
if (state.page > 1) {
state.page--;
loadAllData(false);
}
}
function nextPage() {
if (state.data && state.page < state.data.pagination.total_pages) {
state.page++;
loadAllData(false);
}
}
// ============================================================
// Auto-refresh
// ============================================================
function startAutoRefresh() {
if (state.refreshTimer) {
clearInterval(state.refreshTimer);
}
state.refreshTimer = setInterval(() => {
if (!document.hidden) {
loadAllData(false);
}
}, state.REFRESH_INTERVAL);
}
function manualRefresh() {
startAutoRefresh();
loadAllData(false);
}
// ============================================================
// Lot Detail Functions
// ============================================================
let selectedLotId = null;
async function fetchLotDetail(lotId) {
const result = await MesApi.get(`/api/wip/lot/${encodeURIComponent(lotId)}`, {
timeout: API_TIMEOUT
});
if (result.success) {
return result.data;
}
throw new Error(result.error || 'Failed to fetch lot detail');
}
async function showLotDetail(lotId) {
// Update selected state
selectedLotId = lotId;
// Highlight the selected row
document.querySelectorAll('.lot-id-link').forEach(el => {
el.classList.toggle('active', el.textContent === lotId);
});
// Show panel
const panel = document.getElementById('lotDetailPanel');
panel.classList.add('show');
// Update title
document.getElementById('lotDetailLotId').textContent = lotId;
// Show loading
document.getElementById('lotDetailContent').innerHTML = `
<div class="lot-detail-loading">
<span class="loading-spinner"></span>Loading...
</div>
`;
// Scroll to panel
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const data = await fetchLotDetail(lotId);
renderLotDetail(data);
} catch (error) {
console.error('Failed to load lot detail:', error);
document.getElementById('lotDetailContent').innerHTML = `
<div class="lot-detail-loading" style="color: var(--danger);">
載入失敗:${error.message || '未知錯誤'}
</div>
`;
}
}
function renderLotDetail(data) {
const labels = data.fieldLabels || {};
// Helper to format value
const formatValue = (value) => {
if (value === null || value === undefined || value === '') {
return '<span class="empty">-</span>';
}
if (typeof value === 'number') {
return formatNumber(value);
}
return value;
};
// Helper to create field HTML
const field = (key, customLabel = null) => {
const label = customLabel || labels[key] || key;
const value = data[key];
let valueClass = '';
// Special styling for WIP Status
if (key === 'wipStatus') {
valueClass = `status-${(value || '').toLowerCase()}`;
}
return `
<div class="lot-detail-field">
<span class="lot-detail-label">${label}</span>
<span class="lot-detail-value ${valueClass}">${formatValue(value)}</span>
</div>
`;
};
const html = `
<div class="lot-detail-grid">
<!-- Basic Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">基本資訊</div>
${field('lotId')}
${field('workorder')}
${field('wipStatus')}
${field('status')}
${field('qty')}
${field('qty2')}
${field('ageByDays')}
${field('priority')}
</div>
<!-- Product Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">產品資訊</div>
${field('product')}
${field('productLine')}
${field('packageLef')}
${field('pjType')}
${field('pjFunction')}
${field('bop')}
${field('dateCode')}
${field('produceRegion')}
</div>
<!-- Process Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">製程資訊</div>
${field('workcenterGroup')}
${field('workcenter')}
${field('spec')}
${field('specSequence')}
${field('workflow')}
${field('equipment')}
${field('equipmentCount')}
${field('location')}
</div>
<!-- Material Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">物料資訊</div>
${field('waferLotId')}
${field('waferPn')}
${field('waferLotPrefix')}
${field('leadframeName')}
${field('leadframeOption')}
${field('compoundName')}
${field('dieConsumption')}
${field('uts')}
</div>
<!-- Hold Info (if HOLD status) -->
${data.wipStatus === 'HOLD' || data.holdCount > 0 ? `
<div class="lot-detail-section">
<div class="lot-detail-section-title">Hold 資訊</div>
${field('holdReason')}
${field('holdCount')}
${field('holdEmp')}
${field('holdDept')}
${field('holdComment')}
${field('releaseTime')}
${field('releaseEmp')}
${field('releaseComment')}
</div>
` : ''}
<!-- NCR Info (if exists) -->
${data.ncrId ? `
<div class="lot-detail-section">
<div class="lot-detail-section-title">NCR 資訊</div>
${field('ncrId')}
${field('ncrDate')}
</div>
` : ''}
<!-- Comments -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">備註資訊</div>
${field('comment')}
${field('commentDate')}
${field('commentEmp')}
${field('futureHoldComment')}
</div>
<!-- Other Info -->
<div class="lot-detail-section">
<div class="lot-detail-section-title">其他資訊</div>
${field('owner')}
${field('startDate')}
${field('tmttRemaining')}
${field('dataUpdateDate')}
</div>
</div>
`;
document.getElementById('lotDetailContent').innerHTML = html;
}
function closeLotDetail() {
const panel = document.getElementById('lotDetailPanel');
panel.classList.remove('show');
// Remove highlight from selected row
document.querySelectorAll('.lot-id-link').forEach(el => {
el.classList.remove('active');
});
selectedLotId = null;
}
// ============================================================
// Initialize
// ============================================================
async function init() {
// Setup autocomplete for WORKORDER, LOT ID, PACKAGE, and TYPE
setupAutocomplete('filterWorkorder', 'workorderDropdown', 'workorder');
setupAutocomplete('filterLotid', 'lotidDropdown', 'lotid');
setupAutocomplete('filterPackage', 'packageDropdown', 'package');
setupAutocomplete('filterType', 'typeDropdown', 'type');
// Allow Enter key to trigger filter
document.getElementById('filterWorkorder').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
document.getElementById('filterLotid').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
document.getElementById('filterPackage').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
document.getElementById('filterType').addEventListener('keypress', (e) => {
if (e.key === 'Enter') applyFilters();
});
// Get workcenter from URL or use first available
state.workcenter = getUrlParam('workcenter');
// Get filters from URL params (passed from wip_overview)
const urlWorkorder = getUrlParam('workorder');
const urlLotid = getUrlParam('lotid');
const urlPackage = getUrlParam('package');
const urlType = getUrlParam('type');
if (urlWorkorder) {
state.filters.workorder = urlWorkorder;
document.getElementById('filterWorkorder').value = urlWorkorder;
}
if (urlLotid) {
state.filters.lotid = urlLotid;
document.getElementById('filterLotid').value = urlLotid;
}
if (urlPackage) {
state.filters.package = urlPackage;
document.getElementById('filterPackage').value = urlPackage;
}
if (urlType) {
state.filters.type = urlType;
document.getElementById('filterType').value = urlType;
}
if (!state.workcenter) {
// Fetch workcenters and use first one
try {
const workcenters = await fetchWorkcenters();
if (workcenters && workcenters.length > 0) {
state.workcenter = workcenters[0].name;
// Update URL without reload
window.history.replaceState({}, '', `/wip-detail?workcenter=${encodeURIComponent(state.workcenter)}`);
}
} catch (error) {
console.error('Failed to fetch workcenters:', error);
}
}
if (state.workcenter) {
document.getElementById('pageTitle').textContent = `WIP Detail - ${state.workcenter}`;
loadAllData(true);
startAutoRefresh();
// Handle page visibility (must be after workcenter is set)
document.addEventListener('visibilitychange', () => {
if (!document.hidden && state.workcenter) {
loadAllData(false);
startAutoRefresh();
}
});
} else {
document.getElementById('tableContainer').innerHTML =
'<div class="placeholder">No workcenter available</div>';
document.getElementById('loadingOverlay').style.display = 'none';
}
}
window.onload = init;
</script>
{% endif %}
{% endblock %}