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>
1395 lines
46 KiB
HTML
1395 lines
46 KiB
HTML
{% 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">✓</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 %}
|