feat: 設備即時概況新增 JOB 分析與 tooltip 優化

- 新增 JOB 統計卡片顯示有維修單的設備數量
- 點擊 JOBORDER 顯示完整 JOB 詳細資訊 tooltip
- 修正 JOB 數量計算錯誤 (NaN 值被誤計為有效)
- 改善 tooltip UX: 從 hover 改為 click 觸發的浮動視窗
- 移除狀態篩選器 (可透過矩陣點擊篩選)
- 擴充 realtime_equipment_cache 支援完整 JOB 欄位

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-02-02 16:02:47 +08:00
parent 2f50680eaf
commit bb060dd5b2
3 changed files with 306 additions and 69 deletions

View File

@@ -56,6 +56,10 @@ def _load_equipment_status_from_oracle() -> Optional[List[Dict[str, Any]]]:
JOBID,
JOBSTATUS,
CREATEDATE,
CREATEUSERNAME,
CREATEUSER,
TECHNICIANUSERNAME,
TECHNICIANUSER,
SYMPTOMCODE,
CAUSECODE,
REPAIRCODE,
@@ -205,11 +209,21 @@ def _aggregate_by_resourceid(records: List[Dict[str, Any]]) -> List[Dict[str, An
'EQUIPMENTASSETSSTATUS': status,
'EQUIPMENTASSETSSTATUSREASON': first.get('EQUIPMENTASSETSSTATUSREASON'),
'STATUS_CATEGORY': _classify_status(status),
# JOB related fields
'JOBORDER': first.get('JOBORDER'),
'JOBMODEL': first.get('JOBMODEL'),
'JOBSTAGE': first.get('JOBSTAGE'),
'JOBID': first.get('JOBID'),
'JOBSTATUS': first.get('JOBSTATUS'),
'CREATEDATE': first.get('CREATEDATE'),
'CREATEUSERNAME': first.get('CREATEUSERNAME'),
'CREATEUSER': first.get('CREATEUSER'),
'TECHNICIANUSERNAME': first.get('TECHNICIANUSERNAME'),
'TECHNICIANUSER': first.get('TECHNICIANUSER'),
'SYMPTOMCODE': first.get('SYMPTOMCODE'),
'CAUSECODE': first.get('CAUSECODE'),
'REPAIRCODE': first.get('REPAIRCODE'),
# LOT related fields
'LOT_COUNT': len(seen_lots), # Count distinct RUNCARDLOTID
'LOT_DETAILS': lot_details, # LOT details for tooltip
'TOTAL_TRACKIN_QTY': total_qty,

View File

@@ -27,6 +27,33 @@ from mes_dashboard.services.filter_cache import (
)
# ============================================================
# Helper Functions
# ============================================================
def _is_valid_value(value) -> bool:
"""Check if a value is valid (not None, not NaN, not empty string).
Args:
value: The value to check.
Returns:
True if valid, False otherwise.
"""
if value is None:
return False
if isinstance(value, str) and (not value.strip() or value == 'NaT'):
return False
# Check for NaN (pandas NaN or float NaN)
try:
if value != value: # NaN != NaN is True
return False
except (TypeError, ValueError):
pass
return True
# ============================================================
# Resource Base Subquery
# ============================================================
@@ -495,13 +522,23 @@ def get_merged_resource_status(
'EQUIPMENTASSETSSTATUS': realtime.get('EQUIPMENTASSETSSTATUS'),
'EQUIPMENTASSETSSTATUSREASON': realtime.get('EQUIPMENTASSETSSTATUSREASON'),
'STATUS_CATEGORY': realtime.get('STATUS_CATEGORY'),
# JOB related fields
'JOBORDER': realtime.get('JOBORDER'),
'JOBMODEL': realtime.get('JOBMODEL'),
'JOBSTAGE': realtime.get('JOBSTAGE'),
'JOBID': realtime.get('JOBID'),
'JOBSTATUS': realtime.get('JOBSTATUS'),
'CREATEDATE': realtime.get('CREATEDATE'),
'CREATEUSERNAME': realtime.get('CREATEUSERNAME'),
'CREATEUSER': realtime.get('CREATEUSER'),
'TECHNICIANUSERNAME': realtime.get('TECHNICIANUSERNAME'),
'TECHNICIANUSER': realtime.get('TECHNICIANUSER'),
'SYMPTOMCODE': realtime.get('SYMPTOMCODE'),
'CAUSECODE': realtime.get('CAUSECODE'),
'REPAIRCODE': realtime.get('REPAIRCODE'),
# LOT related fields
'LOT_COUNT': realtime.get('LOT_COUNT'),
'LOT_DETAILS': realtime.get('LOT_DETAILS'), # LOT details for tooltip
'LOT_DETAILS': realtime.get('LOT_DETAILS'),
'TOTAL_TRACKIN_QTY': realtime.get('TOTAL_TRACKIN_QTY'),
'LATEST_TRACKIN_TIME': realtime.get('LATEST_TRACKIN_TIME'),
}
@@ -586,8 +623,8 @@ def get_resource_status_summary(
group = record.get('WORKCENTER_GROUP') or 'UNKNOWN'
by_workcenter_group[group] = by_workcenter_group.get(group, 0) + 1
# Count with active job
with_active_job = sum(1 for r in data if r.get('JOBORDER'))
# Count with active job (use _is_valid_value to exclude NaN/None/empty)
with_active_job = sum(1 for r in data if _is_valid_value(r.get('JOBORDER')))
# Count with WIP
with_wip = sum(1 for r in data if (r.get('LOT_COUNT') or 0) > 0)

View File

@@ -153,7 +153,7 @@
/* Summary Cards */
.summary-grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-columns: repeat(10, 1fr);
gap: 10px;
margin-bottom: 16px;
}
@@ -198,6 +198,7 @@
.summary-card.sdt::before { background: var(--warning); }
.summary-card.engineering::before { background: var(--purple); }
.summary-card.nst::before { background: #94a3b8; }
.summary-card.job::before { background: #f97316; }
.summary-card.total::before { background: var(--muted); }
.summary-label {
@@ -221,6 +222,7 @@
.summary-card.sdt .summary-value { color: var(--warning); }
.summary-card.engineering .summary-value { color: var(--purple); }
.summary-card.nst .summary-value { color: #94a3b8; }
.summary-card.job .summary-value { color: #f97316; }
.summary-card.total .summary-value { color: var(--muted); }
.summary-pct {
@@ -459,76 +461,101 @@
color: var(--muted);
}
/* LOT Tooltip */
.lot-info {
position: relative;
/* Shared Tooltip Styles */
.info-trigger {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.lot-info:hover .lot-tooltip {
display: block;
.info-trigger:hover {
color: var(--primary);
}
.lot-tooltip {
.floating-tooltip {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
position: fixed;
background: #1e293b;
color: #fff;
padding: 12px;
border-radius: 8px;
font-size: 11px;
white-space: nowrap;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
min-width: 280px;
max-width: 400px;
max-height: 300px;
padding: 14px;
border-radius: 10px;
font-size: 12px;
z-index: 9999;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
max-width: 420px;
max-height: 400px;
overflow-y: auto;
}
.lot-tooltip::after {
content: '';
position: absolute;
bottom: 100%;
left: 20px;
border: 6px solid transparent;
border-bottom-color: #1e293b;
.floating-tooltip.show {
display: block;
}
.lot-tooltip-title {
font-weight: 600;
margin-bottom: 8px;
padding-bottom: 6px;
.floating-tooltip-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #475569;
}
.floating-tooltip-title {
font-weight: 600;
color: #94a3b8;
font-size: 13px;
}
.floating-tooltip-close {
background: #475569;
border: none;
color: #fff;
width: 22px;
height: 22px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.floating-tooltip-close:hover {
background: #64748b;
}
/* LOT Tooltip Content */
.lot-tooltip-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.lot-item {
padding: 6px 0;
border-bottom: 1px solid #334155;
}
.lot-item:last-child {
border-bottom: none;
padding: 8px;
background: #334155;
border-radius: 6px;
}
.lot-item-header {
font-weight: 600;
color: #60a5fa;
margin-bottom: 4px;
margin-bottom: 6px;
font-size: 13px;
}
.lot-item-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.lot-item-field {
display: flex;
gap: 4px;
font-size: 11px;
}
.lot-item-label {
@@ -539,6 +566,39 @@
color: #e2e8f0;
}
/* JOB Tooltip Content */
.job-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.job-detail-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.job-detail-field.full-width {
grid-column: 1 / -1;
}
.job-detail-label {
color: #94a3b8;
font-size: 10px;
text-transform: uppercase;
}
.job-detail-value {
color: #e2e8f0;
font-size: 13px;
}
.job-detail-value.highlight {
color: #f59e0b;
font-weight: 600;
}
/* Responsive */
@media (max-width: 1200px) {
.summary-grid { grid-template-columns: repeat(3, 1fr); }
@@ -575,18 +635,6 @@
</select>
</div>
<div class="filter-group">
<label>狀態:</label>
<select id="filterStatus" class="filter-select" onchange="loadData()">
<option value="">全部狀態</option>
<option value="PRODUCTIVE">生產中 (PRD)</option>
<option value="STANDBY">待機 (SBY)</option>
<option value="DOWN">停機 (UDT/SDT)</option>
<option value="ENGINEERING">工程 (EGT)</option>
<option value="NOT_SCHEDULED">未排程 (NST)</option>
</select>
</div>
<label class="filter-checkbox" id="chkProduction">
<input type="checkbox" onchange="toggleFilter(this, 'chkProduction')"> 生產設備
</label>
@@ -649,6 +697,11 @@
<div class="summary-value" id="nstCount">--</div>
<div class="summary-pct" id="nstPct">未排程</div>
</div>
<div class="summary-card job">
<div class="summary-label">JOB</div>
<div class="summary-value" id="jobCount">--</div>
<div class="summary-pct">有維修單</div>
</div>
<div class="summary-card total">
<div class="summary-label">機台數</div>
<div class="summary-value" id="totalCount">--</div>
@@ -680,6 +733,15 @@
</div>
</div>
</div>
<!-- Floating Tooltip (shared) -->
<div class="floating-tooltip" id="floatingTooltip">
<div class="floating-tooltip-header">
<span class="floating-tooltip-title" id="tooltipTitle"></span>
<button class="floating-tooltip-close" onclick="hideTooltip()">&times;</button>
</div>
<div id="tooltipContent"></div>
</div>
</div>
{% endblock %}
@@ -705,9 +767,6 @@
const group = document.getElementById('filterGroup').value;
if (group) params.append('workcenter_groups', group);
const status = document.getElementById('filterStatus').value;
if (status) params.append('status_categories', status);
if (document.querySelector('#chkProduction input').checked) {
params.append('is_production', 'true');
}
@@ -787,6 +846,10 @@
document.getElementById('nstCount').textContent = nst;
document.getElementById('nstPct').textContent = totalStatus ? `未排程 (${((nst/totalStatus)*100).toFixed(1)}%)` : '未排程';
// Update JOB count (equipment with active maintenance/repair job)
const jobCount = d.with_active_job || 0;
document.getElementById('jobCount').textContent = jobCount;
// Update total count
document.getElementById('totalCount').textContent = total;
}
@@ -893,15 +956,95 @@
}
}
function renderLotTooltip(lotDetails) {
if (!lotDetails || lotDetails.length === 0) return '';
// ============================================================
// Floating Tooltip Functions
// ============================================================
let currentTooltipData = null;
let tooltipHtml = '<div class="lot-tooltip"><div class="lot-tooltip-title">在製批次明細</div>';
function showTooltip(event, type, data) {
event.stopPropagation();
const tooltip = document.getElementById('floatingTooltip');
const titleEl = document.getElementById('tooltipTitle');
const contentEl = document.getElementById('tooltipContent');
// Set content based on type
if (type === 'lot') {
titleEl.textContent = '在製批次明細';
contentEl.innerHTML = renderLotContent(data);
} else if (type === 'job') {
titleEl.textContent = 'JOB 單詳細資訊';
contentEl.innerHTML = renderJobContent(data);
}
// Position the tooltip
tooltip.classList.add('show');
// Get dimensions
const rect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate initial position near the click
let x = event.clientX + 10;
let y = event.clientY + 10;
// Adjust if overflowing right
if (x + rect.width > viewportWidth - 20) {
x = event.clientX - rect.width - 10;
}
// Adjust if overflowing bottom
if (y + rect.height > viewportHeight - 20) {
y = viewportHeight - rect.height - 20;
}
// Ensure not off-screen left or top
x = Math.max(10, x);
y = Math.max(10, y);
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
currentTooltipData = { type, data };
}
function hideTooltip() {
const tooltip = document.getElementById('floatingTooltip');
tooltip.classList.remove('show');
currentTooltipData = null;
}
// Close tooltip when clicking outside
document.addEventListener('click', (e) => {
const tooltip = document.getElementById('floatingTooltip');
if (tooltip && !tooltip.contains(e.target) && !e.target.classList.contains('info-trigger')) {
hideTooltip();
}
});
// Helper functions to show specific tooltip types
function showLotTooltip(event, resourceId) {
const eq = allEquipment.find(e => e.RESOURCEID === resourceId);
if (eq && eq.LOT_DETAILS) {
showTooltip(event, 'lot', eq.LOT_DETAILS);
}
}
function showJobTooltip(event, resourceId) {
const eq = allEquipment.find(e => e.RESOURCEID === resourceId);
if (eq && eq.JOBORDER) {
showTooltip(event, 'job', eq);
}
}
function renderLotContent(lotDetails) {
if (!lotDetails || lotDetails.length === 0) return '<div style="color: #94a3b8;">無批次資料</div>';
let html = '<div class="lot-tooltip-content">';
lotDetails.forEach(lot => {
const trackinTime = lot.LOTTRACKINTIME ? new Date(lot.LOTTRACKINTIME).toLocaleString('zh-TW') : '--';
const qty = lot.LOTTRACKINQTY_PCS != null ? lot.LOTTRACKINQTY_PCS.toLocaleString() : '--';
tooltipHtml += `
html += `
<div class="lot-item">
<div class="lot-item-header">${lot.RUNCARDLOTID || '--'}</div>
<div class="lot-item-row">
@@ -912,9 +1055,45 @@
</div>
`;
});
html += '</div>';
return html;
}
tooltipHtml += '</div>';
return tooltipHtml;
function renderJobContent(eq) {
const formatDate = (dateStr) => {
if (!dateStr) return '--';
try {
return new Date(dateStr).toLocaleString('zh-TW');
} catch {
return dateStr;
}
};
const field = (label, value, isHighlight = false) => {
const valueClass = isHighlight ? 'highlight' : '';
return `
<div class="job-detail-field">
<span class="job-detail-label">${label}</span>
<span class="job-detail-value ${valueClass}">${value || '--'}</span>
</div>
`;
};
return `
<div class="job-detail-grid">
${field('JOBORDER', eq.JOBORDER, true)}
${field('JOBSTATUS', eq.JOBSTATUS, true)}
${field('JOBMODEL', eq.JOBMODEL)}
${field('JOBSTAGE', eq.JOBSTAGE)}
${field('JOBID', eq.JOBID)}
${field('建立時間', formatDate(eq.CREATEDATE))}
${field('建立人員', eq.CREATEUSERNAME || eq.CREATEUSER)}
${field('技術員', eq.TECHNICIANUSERNAME || eq.TECHNICIANUSER)}
${field('症狀碼', eq.SYMPTOMCODE)}
${field('原因碼', eq.CAUSECODE)}
${field('維修碼', eq.REPAIRCODE)}
</div>
`;
}
function renderEquipmentList(equipment) {
@@ -927,15 +1106,22 @@
let html = '<div class="equipment-grid">';
equipment.forEach(eq => {
equipment.forEach((eq) => {
const statusCat = (eq.STATUS_CATEGORY || 'OTHER').toLowerCase();
const statusDisplay = getStatusDisplay(eq.EQUIPMENTASSETSSTATUS, eq.STATUS_CATEGORY);
const resourceId = eq.RESOURCEID || '';
const escapedResourceId = resourceId.replace(/'/g, "\\'");
// Build LOT info with tooltip
// Build LOT info with click trigger
let lotHtml = '';
if (eq.LOT_COUNT > 0) {
const tooltipHtml = renderLotTooltip(eq.LOT_DETAILS);
lotHtml = `<span class="lot-info">📦 ${eq.LOT_COUNT}${tooltipHtml}</span>`;
lotHtml = `<span class="info-trigger" onclick="showLotTooltip(event, '${escapedResourceId}')" title="點擊查看批次詳情">📦 ${eq.LOT_COUNT} 批</span>`;
}
// Build JOB info with click trigger
let jobHtml = '';
if (eq.JOBORDER) {
jobHtml = `<span class="info-trigger" onclick="showJobTooltip(event, '${escapedResourceId}')" title="點擊查看JOB詳情">📋 ${eq.JOBORDER}</span>`;
}
html += `
@@ -950,7 +1136,7 @@
<span title="家族">🔧 ${eq.RESOURCEFAMILYNAME || '--'}</span>
<span title="區域">🏢 ${eq.LOCATIONNAME || '--'}</span>
${lotHtml}
${eq.JOBORDER ? `<span title="工單">📋 ${eq.JOBORDER}</span>` : ''}
${jobHtml}
</div>
</div>
`;