feat: 設備頁面統一排序、階層篩選與標籤優化
- 統一設備即時概況與歷史績效頁面的 workcenter 排序,依據 WORKCENTERSEQUENCE_GROUP 升序排列 - 設備即時概況的工站狀態矩陣支援階層點選篩選:站點 → 型號 → 機台 - 將 Availability% 標籤統一改為 AVAIL% Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user