feat: 設備頁面統一排序、階層篩選與標籤優化

- 統一設備即時概況與歷史績效頁面的 workcenter 排序,依據 WORKCENTERSEQUENCE_GROUP 升序排列
- 設備即時概況的工站狀態矩陣支援階層點選篩選:站點 → 型號 → 機台
- 將 Availability% 標籤統一改為 AVAIL%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-02-02 17:16:27 +08:00
parent bb060dd5b2
commit 65f43fbe0c
5 changed files with 459 additions and 105 deletions

View File

@@ -96,6 +96,21 @@ def get_workcenter_group(workcenter_name: str) -> Optional[str]:
return mapping[workcenter_name].get('group')
def get_workcenter_group_sequence(workcenter_name: str) -> Optional[int]:
"""Get workcenter group sequence for a workcenter name.
Args:
workcenter_name: The workcenter name to look up.
Returns:
The WORKCENTERSEQUENCE_GROUP, or None if not found.
"""
mapping = get_workcenter_mapping()
if not mapping or workcenter_name not in mapping:
return None
return mapping[workcenter_name].get('sequence')
def get_workcenter_short(workcenter_name: str) -> Optional[str]:
"""Get workcenter short name for a workcenter name.

View File

@@ -801,6 +801,8 @@ def _build_heatmap_from_raw_df(
wc_mapping = get_workcenter_mapping() or {}
# Aggregate data by WORKCENTER_GROUP and date
# Track sequence for each workcenter group
wc_seq_map = {}
aggregated = {}
for _, row in df.iterrows():
historyid = row['HISTORYID']
@@ -816,6 +818,8 @@ def _build_heatmap_from_raw_df(
wc_info = wc_mapping.get(wc_name, {})
wc_group = wc_info.get('group', wc_name)
wc_seq = wc_info.get('sequence', 999)
wc_seq_map[wc_group] = wc_seq # Store sequence for this group
date_str = _format_date(row['DATA_DATE'], granularity)
key = (wc_group, date_str)
@@ -832,12 +836,13 @@ def _build_heatmap_from_raw_df(
for (wc_group, date_str), data in aggregated.items():
result.append({
'workcenter': wc_group,
'workcenter_seq': wc_seq_map.get(wc_group, 999),
'date': date_str,
'ou_pct': _calc_ou_pct(data['prd'], data['sby'], data['udt'], data['sdt'], data['egt'])
})
# Sort by workcenter and date
result.sort(key=lambda x: (x['workcenter'], x['date'] or ''))
# Sort by workcenter sequence (ascending, smaller first) and date
result.sort(key=lambda x: (x['workcenter_seq'], x['date'] or ''))
return result
@@ -959,11 +964,13 @@ def _build_detail_from_raw_df(
wc_name = resource_info.get('WORKCENTERNAME', '')
wc_info = wc_mapping.get(wc_name, {})
wc_group = wc_info.get('group', wc_name) # Fallback to workcentername if no mapping
wc_seq = wc_info.get('sequence', 999) # Get sequence for sorting
family = resource_info.get('RESOURCEFAMILYNAME', '')
resource_name = resource_info.get('RESOURCENAME', '')
result.append({
'workcenter': wc_group,
'workcenter_seq': wc_seq,
'family': family or '',
'resource': resource_name or '',
'ou_pct': _calc_ou_pct(prd, sby, udt, sdt, egt),
@@ -983,6 +990,6 @@ def _build_detail_from_raw_df(
'machine_count': 1
})
# Sort by workcenter, family, resource
result.sort(key=lambda x: (x['workcenter'], x['family'], x['resource']))
# Sort by workcenter sequence (ascending, smaller first), then family, resource
result.sort(key=lambda x: (x['workcenter_seq'], x['family'], x['resource']))
return result

View File

@@ -22,6 +22,7 @@ from mes_dashboard.services.realtime_equipment_cache import (
)
from mes_dashboard.services.filter_cache import (
get_workcenter_group,
get_workcenter_group_sequence,
get_workcenter_short,
get_workcenter_groups,
)
@@ -498,6 +499,7 @@ def get_merged_resource_status(
# Get workcenter mapping
wc_group = get_workcenter_group(workcenter_name) if workcenter_name else None
wc_group_seq = get_workcenter_group_sequence(workcenter_name) if workcenter_name else None
wc_short = get_workcenter_short(workcenter_name) if workcenter_name else None
# Build merged record
@@ -517,6 +519,7 @@ def get_merged_resource_status(
'LOCATIONNAME': resource.get('LOCATIONNAME'),
# From workcenter-mapping
'WORKCENTER_GROUP': wc_group,
'WORKCENTER_GROUP_SEQ': wc_group_seq,
'WORKCENTER_SHORT': wc_short,
# From realtime-equipment-cache
'EQUIPMENTASSETSSTATUS': realtime.get('EQUIPMENTASSETSSTATUS'),

View File

@@ -631,7 +631,7 @@
<div class="kpi-sub">稼動率</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Availability%</div>
<div class="kpi-label">AVAIL%</div>
<div class="kpi-value green" id="kpiAvailabilityPct">--</div>
<div class="kpi-sub">可用率</div>
</div>
@@ -675,7 +675,7 @@
<!-- Charts Row 1 -->
<div class="charts-row">
<div class="chart-card">
<div class="chart-title">OU% / Availability% 趨勢</div>
<div class="chart-title">OU% / AVAIL% 趨勢</div>
<div class="chart-container" id="trendChart"></div>
</div>
<div class="chart-card">
@@ -1032,7 +1032,7 @@
// KPI Cards
// ============================================================
function updateKpiCards(kpi) {
// OU% and Availability%
// OU% and AVAIL%
document.getElementById('kpiOuPct').textContent = kpi.ou_pct + '%';
document.getElementById('kpiAvailabilityPct').textContent = kpi.availability_pct + '%';
@@ -1086,14 +1086,14 @@
const d = trend[params[0].dataIndex];
return `${d.date}<br/>
<span style="color:#3B82F6">●</span> OU%: <b>${d.ou_pct}%</b><br/>
<span style="color:#10B981">●</span> Availability%: <b>${d.availability_pct}%</b><br/>
<span style="color:#10B981">●</span> AVAIL%: <b>${d.availability_pct}%</b><br/>
PRD: ${d.prd_hours}h<br/>
SBY: ${d.sby_hours}h<br/>
UDT: ${d.udt_hours}h`;
}
},
legend: {
data: ['OU%', 'Availability%'],
data: ['OU%', 'AVAIL%'],
bottom: 0,
textStyle: { fontSize: 11 }
},
@@ -1119,7 +1119,7 @@
lineStyle: { width: 2 }
},
{
name: 'Availability%',
name: 'AVAIL%',
data: availabilityPcts,
type: 'line',
smooth: true,
@@ -1229,8 +1229,15 @@
return;
}
// Get unique workcenters and dates
const workcenters = [...new Set(heatmap.map(h => h.workcenter))];
// Build workcenter list with sequence for sorting
const wcSeqMap = {};
heatmap.forEach(h => {
wcSeqMap[h.workcenter] = h.workcenter_seq ?? 999;
});
// Get unique workcenters sorted by sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
const workcenters = [...new Set(heatmap.map(h => h.workcenter))]
.sort((a, b) => wcSeqMap[a] - wcSeqMap[b]);
const dates = [...new Set(heatmap.map(h => h.date))].sort();
// Build data matrix
@@ -1338,11 +1345,13 @@
data.forEach(item => {
const wc = item.workcenter;
const fam = item.family;
const wcSeq = item.workcenter_seq ?? 999;
if (!wcMap[wc]) {
wcMap[wc] = {
workcenter: wc,
name: wc,
sequence: wcSeq,
families: [],
familyMap: {},
ou_pct: 0, prd_hours: 0, prd_pct: 0,
@@ -1399,7 +1408,8 @@
});
});
return Object.values(wcMap).sort((a, b) => b.ou_pct - a.ou_pct);
// Sort by workcenter sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
return Object.values(wcMap).sort((a, b) => a.sequence - b.sequence);
}
function calcPercentages(obj) {

View File

@@ -259,6 +259,30 @@
border-radius: 2px;
}
/* Matrix Actions */
.matrix-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.btn-matrix-action {
padding: 4px 10px;
font-size: 12px;
background: #f1f5f9;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--muted);
transition: all 0.2s;
}
.btn-matrix-action:hover {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
/* Matrix Table */
.matrix-table {
width: 100%;
@@ -318,6 +342,70 @@
box-shadow: inset 0 0 0 2px var(--primary);
}
/* Clickable equipment rows */
.matrix-table tr.clickable-row {
cursor: pointer;
transition: background 0.15s;
}
.matrix-table tr.clickable-row:hover {
background: #f0f4ff !important;
}
.matrix-table tr.clickable-row.selected {
background: #dbeafe !important;
box-shadow: inset 0 0 0 2px var(--primary);
}
/* Hierarchical Matrix Rows */
.matrix-table .row-level-0 {
background: #f8f9fb;
font-weight: 600;
}
.matrix-table .row-level-1 {
background: #fafbfc;
}
.matrix-table .row-level-2 {
background: #fff;
font-size: 12px;
}
.matrix-table .expand-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
font-size: 10px;
color: var(--primary);
transition: transform 0.2s;
display: inline-block;
width: 20px;
}
.matrix-table .expand-btn:hover {
color: var(--primary-dark);
}
.matrix-table .expand-btn.expanded {
transform: rotate(90deg);
}
.matrix-table .expand-placeholder {
display: inline-block;
width: 20px;
}
.matrix-table .indent-1 td:first-child { padding-left: 30px; }
.matrix-table .indent-2 td:first-child { padding-left: 50px; }
.matrix-table .row-name {
display: flex;
align-items: center;
gap: 4px;
}
/* Matrix filter indicator */
.matrix-filter-indicator {
display: none;
@@ -663,7 +751,7 @@
<div class="summary-pct">稼動率</div>
</div>
<div class="summary-card availability">
<div class="summary-label">Availability%</div>
<div class="summary-label">AVAIL%</div>
<div class="summary-value" id="availabilityPct">--</div>
<div class="summary-pct">可用率</div>
</div>
@@ -711,7 +799,13 @@
<!-- Matrix Section -->
<div class="section">
<div class="section-title">工站狀態矩陣</div>
<div class="section-title">
工站狀態矩陣
<div class="matrix-actions">
<button class="btn-matrix-action" onclick="toggleAllMatrixRows(true)">全部展開</button>
<button class="btn-matrix-action" onclick="toggleAllMatrixRows(false)">全部收合</button>
</div>
</div>
<div id="matrixContainer">
<div class="loading-container">
<span class="spinner"></span> 載入中...
@@ -750,6 +844,235 @@
let allEquipment = [];
let workcenterGroups = [];
let matrixFilter = null; // { workcenter_group, status }
let matrixHierarchyState = {}; // Track expanded/collapsed state for matrix rows
// ============================================================
// Hierarchical Matrix Functions
// ============================================================
function buildMatrixHierarchy(equipment) {
// Build hierarchy: workcenter_group -> resourcefamily -> equipment
const groupMap = {};
equipment.forEach(eq => {
const group = eq.WORKCENTER_GROUP || 'UNKNOWN';
const family = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
const status = eq.EQUIPMENTASSETSSTATUS || 'OTHER';
const groupSeq = eq.WORKCENTER_GROUP_SEQ ?? 999;
// Initialize group
if (!groupMap[group]) {
groupMap[group] = {
name: group,
sequence: groupSeq,
families: {},
counts: { total: 0, PRD: 0, SBY: 0, UDT: 0, SDT: 0, EGT: 0, NST: 0, OTHER: 0 }
};
}
// Initialize family
if (!groupMap[group].families[family]) {
groupMap[group].families[family] = {
name: family,
equipment: [],
counts: { total: 0, PRD: 0, SBY: 0, UDT: 0, SDT: 0, EGT: 0, NST: 0, OTHER: 0 }
};
}
// Add equipment to family
groupMap[group].families[family].equipment.push(eq);
// Map status to count key
let statusKey = 'OTHER';
if (['PRD'].includes(status)) statusKey = 'PRD';
else if (['SBY'].includes(status)) statusKey = 'SBY';
else if (['UDT', 'PM', 'BKD'].includes(status)) statusKey = 'UDT';
else if (['SDT'].includes(status)) statusKey = 'SDT';
else if (['EGT', 'ENG'].includes(status)) statusKey = 'EGT';
else if (['NST', 'OFF'].includes(status)) statusKey = 'NST';
// Update counts
groupMap[group].counts.total++;
groupMap[group].counts[statusKey]++;
groupMap[group].families[family].counts.total++;
groupMap[group].families[family].counts[statusKey]++;
});
// Convert to array structure
// Sort groups by sequence ascending (smaller sequence first, e.g. 點測 before TMTT)
// Sort families by total count descending
const hierarchy = Object.values(groupMap).map(g => ({
...g,
families: Object.values(g.families).sort((a, b) => b.counts.total - a.counts.total)
})).sort((a, b) => a.sequence - b.sequence);
return hierarchy;
}
function toggleMatrixRow(rowId) {
matrixHierarchyState[rowId] = !matrixHierarchyState[rowId];
renderMatrixHierarchy();
}
function toggleAllMatrixRows(expand) {
const hierarchy = buildMatrixHierarchy(allEquipment);
hierarchy.forEach(group => {
matrixHierarchyState[`grp_${group.name}`] = expand;
group.families.forEach(fam => {
matrixHierarchyState[`fam_${group.name}_${fam.name}`] = expand;
});
});
renderMatrixHierarchy();
}
function renderMatrixHierarchy() {
const container = document.getElementById('matrixContainer');
const hierarchy = buildMatrixHierarchy(allEquipment);
if (hierarchy.length === 0) {
container.innerHTML = '<div class="empty-state">無資料</div>';
return;
}
let html = `
<table class="matrix-table">
<thead>
<tr>
<th>工站群組 / 型號 / 機台</th>
<th>總數</th>
<th>PRD</th>
<th>SBY</th>
<th>UDT</th>
<th>SDT</th>
<th>EGT</th>
<th>NST</th>
<th>OTHER</th>
<th>OU%</th>
</tr>
</thead>
<tbody>
`;
hierarchy.forEach(group => {
const grpId = `grp_${group.name}`;
const isGroupExpanded = matrixHierarchyState[grpId];
const hasChildren = group.families.length > 0;
// Calculate OU%
const avail = group.counts.PRD + group.counts.SBY + group.counts.UDT + group.counts.SDT + group.counts.EGT;
const ou = avail > 0 ? ((group.counts.PRD / avail) * 100).toFixed(1) : 0;
const ouClass = ou >= 80 ? 'high' : (ou >= 50 ? 'medium' : 'low');
// Group row (Level 0)
const expandBtn = hasChildren
? `<button class="expand-btn ${isGroupExpanded ? 'expanded' : ''}" onclick="toggleMatrixRow('${grpId}')">▶</button>`
: '<span class="expand-placeholder"></span>';
// Helper to check if this cell is selected (supports all levels)
const isSelected = (wg, st, fam = null, res = null) => {
if (!matrixFilter) return false;
if (matrixFilter.workcenter_group !== wg) return false;
if (matrixFilter.status !== st) return false;
if (fam !== null && matrixFilter.family !== fam) return false;
if (res !== null && matrixFilter.resource !== res) return false;
// Match level: if matrixFilter has family but we're checking group level, no match
if (matrixFilter.family && fam === null) return false;
if (matrixFilter.resource && res === null) return false;
return true;
};
const grpName = group.name;
html += `
<tr class="row-level-0">
<td><span class="row-name">${expandBtn}${group.name}</span></td>
<td class="col-total">${group.counts.total}</td>
<td class="col-prd clickable ${group.counts.PRD === 0 ? 'zero' : ''} ${isSelected(grpName, 'PRD') ? 'selected' : ''}" data-wg="${grpName}" data-status="PRD" onclick="filterByMatrixCell('${grpName}', 'PRD')">${group.counts.PRD}</td>
<td class="col-sby clickable ${group.counts.SBY === 0 ? 'zero' : ''} ${isSelected(grpName, 'SBY') ? 'selected' : ''}" data-wg="${grpName}" data-status="SBY" onclick="filterByMatrixCell('${grpName}', 'SBY')">${group.counts.SBY}</td>
<td class="col-udt clickable ${group.counts.UDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'UDT') ? 'selected' : ''}" data-wg="${grpName}" data-status="UDT" onclick="filterByMatrixCell('${grpName}', 'UDT')">${group.counts.UDT}</td>
<td class="col-sdt clickable ${group.counts.SDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'SDT') ? 'selected' : ''}" data-wg="${grpName}" data-status="SDT" onclick="filterByMatrixCell('${grpName}', 'SDT')">${group.counts.SDT}</td>
<td class="col-egt clickable ${group.counts.EGT === 0 ? 'zero' : ''} ${isSelected(grpName, 'EGT') ? 'selected' : ''}" data-wg="${grpName}" data-status="EGT" onclick="filterByMatrixCell('${grpName}', 'EGT')">${group.counts.EGT}</td>
<td class="col-nst clickable ${group.counts.NST === 0 ? 'zero' : ''} ${isSelected(grpName, 'NST') ? 'selected' : ''}" data-wg="${grpName}" data-status="NST" onclick="filterByMatrixCell('${grpName}', 'NST')">${group.counts.NST}</td>
<td class="col-other clickable ${group.counts.OTHER === 0 ? 'zero' : ''} ${isSelected(grpName, 'OTHER') ? 'selected' : ''}" data-wg="${grpName}" data-status="OTHER" onclick="filterByMatrixCell('${grpName}', 'OTHER')">${group.counts.OTHER}</td>
<td><span class="ou-badge ${ouClass}">${ou}%</span></td>
</tr>
`;
// Family rows (Level 1)
if (isGroupExpanded) {
group.families.forEach(fam => {
const famId = `fam_${group.name}_${fam.name}`;
const isFamExpanded = matrixHierarchyState[famId];
const hasEquipment = fam.equipment.length > 0;
const famAvail = fam.counts.PRD + fam.counts.SBY + fam.counts.UDT + fam.counts.SDT + fam.counts.EGT;
const famOu = famAvail > 0 ? ((fam.counts.PRD / famAvail) * 100).toFixed(1) : 0;
const famOuClass = famOu >= 80 ? 'high' : (famOu >= 50 ? 'medium' : 'low');
const famExpandBtn = hasEquipment
? `<button class="expand-btn ${isFamExpanded ? 'expanded' : ''}" onclick="toggleMatrixRow('${famId}')">▶</button>`
: '<span class="expand-placeholder"></span>';
const famName = fam.name;
const escFamName = famName.replace(/'/g, "\\'");
html += `
<tr class="row-level-1 indent-1">
<td><span class="row-name">${famExpandBtn}${fam.name}</span></td>
<td class="col-total">${fam.counts.total}</td>
<td class="col-prd clickable ${fam.counts.PRD === 0 ? 'zero' : ''} ${isSelected(grpName, 'PRD', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="PRD" onclick="filterByMatrixCell('${grpName}', 'PRD', '${escFamName}')">${fam.counts.PRD}</td>
<td class="col-sby clickable ${fam.counts.SBY === 0 ? 'zero' : ''} ${isSelected(grpName, 'SBY', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="SBY" onclick="filterByMatrixCell('${grpName}', 'SBY', '${escFamName}')">${fam.counts.SBY}</td>
<td class="col-udt clickable ${fam.counts.UDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'UDT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="UDT" onclick="filterByMatrixCell('${grpName}', 'UDT', '${escFamName}')">${fam.counts.UDT}</td>
<td class="col-sdt clickable ${fam.counts.SDT === 0 ? 'zero' : ''} ${isSelected(grpName, 'SDT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="SDT" onclick="filterByMatrixCell('${grpName}', 'SDT', '${escFamName}')">${fam.counts.SDT}</td>
<td class="col-egt clickable ${fam.counts.EGT === 0 ? 'zero' : ''} ${isSelected(grpName, 'EGT', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="EGT" onclick="filterByMatrixCell('${grpName}', 'EGT', '${escFamName}')">${fam.counts.EGT}</td>
<td class="col-nst clickable ${fam.counts.NST === 0 ? 'zero' : ''} ${isSelected(grpName, 'NST', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="NST" onclick="filterByMatrixCell('${grpName}', 'NST', '${escFamName}')">${fam.counts.NST}</td>
<td class="col-other clickable ${fam.counts.OTHER === 0 ? 'zero' : ''} ${isSelected(grpName, 'OTHER', famName) ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-status="OTHER" onclick="filterByMatrixCell('${grpName}', 'OTHER', '${escFamName}')">${fam.counts.OTHER}</td>
<td><span class="ou-badge ${famOuClass}">${famOu}%</span></td>
</tr>
`;
// Equipment rows (Level 2)
if (isFamExpanded) {
fam.equipment.forEach(eq => {
const status = eq.EQUIPMENTASSETSSTATUS || '--';
const statusCat = (eq.STATUS_CATEGORY || 'OTHER').toLowerCase();
const resId = eq.RESOURCEID || '';
const resName = eq.RESOURCENAME || eq.RESOURCEID || '--';
const escResId = resId.replace(/'/g, "\\'");
// Determine status category key for this equipment
let eqStatusKey = 'OTHER';
if (['PRD'].includes(status)) eqStatusKey = 'PRD';
else if (['SBY'].includes(status)) eqStatusKey = 'SBY';
else if (['UDT', 'PM', 'BKD'].includes(status)) eqStatusKey = 'UDT';
else if (['SDT'].includes(status)) eqStatusKey = 'SDT';
else if (['EGT', 'ENG'].includes(status)) eqStatusKey = 'EGT';
else if (['NST', 'OFF'].includes(status)) eqStatusKey = 'NST';
const isEqSelected = isSelected(grpName, eqStatusKey, famName, resId);
html += `
<tr class="row-level-2 indent-2 clickable-row ${isEqSelected ? 'selected' : ''}" data-wg="${grpName}" data-fam="${famName}" data-res="${resId}" onclick="filterByMatrixCell('${grpName}', '${eqStatusKey}', '${escFamName}', '${escResId}')">
<td><span class="row-name"><span class="expand-placeholder"></span>${resName}</span></td>
<td>1</td>
<td class="col-prd ${status !== 'PRD' ? 'zero' : ''}">${status === 'PRD' ? '●' : '-'}</td>
<td class="col-sby ${status !== 'SBY' ? 'zero' : ''}">${status === 'SBY' ? '●' : '-'}</td>
<td class="col-udt ${!['UDT', 'PM', 'BKD'].includes(status) ? 'zero' : ''}">${['UDT', 'PM', 'BKD'].includes(status) ? '●' : '-'}</td>
<td class="col-sdt ${status !== 'SDT' ? 'zero' : ''}">${status === 'SDT' ? '●' : '-'}</td>
<td class="col-egt ${!['EGT', 'ENG'].includes(status) ? 'zero' : ''}">${['EGT', 'ENG'].includes(status) ? '●' : '-'}</td>
<td class="col-nst ${!['NST', 'OFF'].includes(status) ? 'zero' : ''}">${['NST', 'OFF'].includes(status) ? '●' : '-'}</td>
<td class="col-other">${!['PRD', 'SBY', 'UDT', 'PM', 'BKD', 'SDT', 'EGT', 'ENG', 'NST', 'OFF'].includes(status) ? '●' : '-'}</td>
<td><span class="eq-status ${statusCat}">${status}</span></td>
</tr>
`;
});
}
});
}
});
html += '</tbody></table>';
container.innerHTML = html;
}
function toggleFilter(checkbox, id) {
const label = document.getElementById(id);
@@ -823,7 +1146,7 @@
// Calculate percentage denominator (includes NST)
const totalStatus = prd + sby + udt + sdt + egt + nst;
// Update OU% and Availability%
// Update OU% and AVAIL%
document.getElementById('ouPct').textContent = d.ou_pct ? `${d.ou_pct}%` : '--';
document.getElementById('availabilityPct').textContent = d.availability_pct ? `${d.availability_pct}%` : '--';
@@ -858,75 +1181,10 @@
}
}
async function loadMatrix() {
const container = document.getElementById('matrixContainer');
try {
const queryString = getFilters();
const resp = await fetch(`/api/resource/status/matrix?${queryString}`);
const result = await resp.json();
if (result.success && result.data.length > 0) {
let html = `
<table class="matrix-table">
<thead>
<tr>
<th>工站群組</th>
<th>總數</th>
<th>PRD</th>
<th>SBY</th>
<th>UDT</th>
<th>SDT</th>
<th>EGT</th>
<th>NST</th>
<th>OTHER</th>
<th>OU%</th>
</tr>
</thead>
<tbody>
`;
result.data.forEach(row => {
const total = row.total || 1;
const avail = row.PRD + row.SBY + row.UDT + row.SDT + row.EGT;
const ou = avail > 0 ? ((row.PRD / avail) * 100).toFixed(1) : 0;
const ouClass = ou >= 80 ? 'high' : (ou >= 50 ? 'medium' : 'low');
const wg = row.workcenter_group;
// Helper to render clickable cell
const renderCell = (status, value, colClass) => {
if (value === 0) {
return `<td class="clickable ${colClass} zero" data-wg="${wg}" data-status="${status}">${value}</td>`;
}
const selected = matrixFilter && matrixFilter.workcenter_group === wg && matrixFilter.status === status ? 'selected' : '';
return `<td class="clickable ${colClass} ${selected}" data-wg="${wg}" data-status="${status}" onclick="filterByMatrixCell('${wg}', '${status}')">${value}</td>`;
};
html += `
<tr>
<td>${wg}</td>
<td class="col-total">${row.total}</td>
${renderCell('PRD', row.PRD, 'col-prd')}
${renderCell('SBY', row.SBY, 'col-sby')}
${renderCell('UDT', row.UDT, 'col-udt')}
${renderCell('SDT', row.SDT, 'col-sdt')}
${renderCell('EGT', row.EGT, 'col-egt')}
${renderCell('NST', row.NST, 'col-nst')}
${renderCell('OTHER', row.OTHER, 'col-other')}
<td><span class="ou-badge ${ouClass}">${ou}%</span></td>
</tr>
`;
});
html += '</tbody></table>';
container.innerHTML = html;
} else {
container.innerHTML = '<div class="empty-state">無資料</div>';
}
} catch (e) {
console.error('載入矩陣失敗:', e);
container.innerHTML = '<div class="empty-state">載入失敗</div>';
}
function loadMatrix() {
// Matrix is now rendered from allEquipment data using hierarchy
// This function is called after loadEquipment populates allEquipment
renderMatrixHierarchy();
}
async function loadEquipment() {
@@ -1146,7 +1404,7 @@
container.innerHTML = html;
}
function filterByMatrixCell(workcenterGroup, status) {
function filterByMatrixCell(workcenterGroup, status, family = null, resource = null) {
// Status mapping from matrix column to STATUS_CATEGORY or EQUIPMENTASSETSSTATUS
const statusMap = {
'PRD': 'PRODUCTIVE',
@@ -1158,23 +1416,51 @@
'OTHER': 'OTHER'
};
// Toggle off if clicking same cell
if (matrixFilter && matrixFilter.workcenter_group === workcenterGroup && matrixFilter.status === status) {
// Toggle off if clicking same cell (exact match including family and resource)
if (matrixFilter &&
matrixFilter.workcenter_group === workcenterGroup &&
matrixFilter.status === status &&
matrixFilter.family === family &&
matrixFilter.resource === resource) {
clearMatrixFilter();
return;
}
matrixFilter = { workcenter_group: workcenterGroup, status: status };
matrixFilter = {
workcenter_group: workcenterGroup,
status: status,
family: family,
resource: resource
};
// Update selected cell highlighting
// Update selected cell highlighting for group and family level cells
document.querySelectorAll('.matrix-table td.clickable').forEach(cell => {
cell.classList.remove('selected');
if (cell.dataset.wg === workcenterGroup && cell.dataset.status === status) {
cell.classList.add('selected');
const cellWg = cell.dataset.wg;
const cellStatus = cell.dataset.status;
const cellFam = cell.dataset.fam || null;
// Match based on level
if (cellWg === workcenterGroup && cellStatus === status) {
if (family === null && cellFam === undefined) {
// Group level match
cell.classList.add('selected');
} else if (family !== null && cellFam === family && resource === null) {
// Family level match
cell.classList.add('selected');
}
}
});
// Show filter indicator
// Update selected row highlighting for equipment level
document.querySelectorAll('.matrix-table tr.clickable-row').forEach(row => {
row.classList.remove('selected');
if (resource !== null && row.dataset.res === resource) {
row.classList.add('selected');
}
});
// Show filter indicator with hierarchical label
const statusLabels = {
'PRD': '生產中',
'SBY': '待機',
@@ -1184,26 +1470,51 @@
'NST': '未排程',
'OTHER': '其他'
};
document.getElementById('matrixFilterText').textContent = `${workcenterGroup} - ${statusLabels[status] || status}`;
let filterLabel = workcenterGroup;
if (family) filterLabel += ` / ${family}`;
if (resource) {
// Find resource name from allEquipment
const eqInfo = allEquipment.find(e => e.RESOURCEID === resource);
const resName = eqInfo ? (eqInfo.RESOURCENAME || resource) : resource;
filterLabel += ` / ${resName}`;
}
filterLabel += ` - ${statusLabels[status] || status}`;
document.getElementById('matrixFilterText').textContent = filterLabel;
document.getElementById('matrixFilterIndicator').classList.add('active');
// Filter and render equipment list
const standardStatuses = ['PRD', 'SBY', 'UDT', 'SDT', 'EGT', 'NST'];
// Use same grouping logic as buildMatrixHierarchy
const filtered = allEquipment.filter(eq => {
// Match workcenter group
// Note: If matrix shows "UNKNOWN", it means equipment has no WORKCENTER_GROUP
const eqGroup = eq.WORKCENTER_GROUP || 'UNKNOWN';
if (eqGroup !== workcenterGroup) return false;
// Match family if specified
if (family !== null) {
const eqFamily = eq.RESOURCEFAMILYNAME || 'UNKNOWN';
if (eqFamily !== family) return false;
}
// Match resource if specified
if (resource !== null) {
if (eq.RESOURCEID !== resource) return false;
}
// Match status based on EQUIPMENTASSETSSTATUS (same logic as matrix calculation)
const eqStatus = eq.EQUIPMENTASSETSSTATUS || '';
if (status === 'OTHER') {
// OTHER = any status NOT in the standard set
return !standardStatuses.includes(eqStatus);
} else {
// For standard statuses, match exact EQUIPMENTASSETSSTATUS
return eqStatus === status;
}
// Map equipment status to matrix status category (same as buildMatrixHierarchy)
let eqStatusKey = 'OTHER';
if (['PRD'].includes(eqStatus)) eqStatusKey = 'PRD';
else if (['SBY'].includes(eqStatus)) eqStatusKey = 'SBY';
else if (['UDT', 'PM', 'BKD'].includes(eqStatus)) eqStatusKey = 'UDT';
else if (['SDT'].includes(eqStatus)) eqStatusKey = 'SDT';
else if (['EGT', 'ENG'].includes(eqStatus)) eqStatusKey = 'EGT';
else if (['NST', 'OFF'].includes(eqStatus)) eqStatusKey = 'NST';
return eqStatusKey === status;
});
document.getElementById('equipmentCount').textContent = filtered.length;
@@ -1213,11 +1524,16 @@
function clearMatrixFilter() {
matrixFilter = null;
// Remove selected highlighting
// Remove selected highlighting from cells
document.querySelectorAll('.matrix-table td.clickable').forEach(cell => {
cell.classList.remove('selected');
});
// Remove selected highlighting from rows
document.querySelectorAll('.matrix-table tr.clickable-row').forEach(row => {
row.classList.remove('selected');
});
// Hide filter indicator
document.getElementById('matrixFilterIndicator').classList.remove('active');
@@ -1290,11 +1606,14 @@
btn.disabled = true;
try {
// loadSummary can run in parallel
// loadEquipment must complete before loadMatrix (matrix uses allEquipment data)
await Promise.all([
loadSummary(),
loadMatrix(),
loadEquipment()
]);
// Now render the matrix from the loaded equipment data
loadMatrix();
} finally {
btn.disabled = false;
}