- 新增 resource_cache.py 模組,Redis 快取 DW_MES_RESOURCE 表 - 實作每 4 小時背景同步(MAX(LASTCHANGEDATE) 版本控制) - 整合 filter_cache 優先從 WIP Redis 快取載入站點群組 - 整合 health 端點顯示 resource_cache 狀態 - 修改 resource_service 與 resource_history_service 使用快取 - 更新表名 DWH.DW_PJ_LOT_V → DW_MES_LOT_V - 新增單元測試 (28 tests) 與 E2E 測試 (15 tests) - 修復 wip_service 測試的 cache mock 問題 - 新增 Oracle 授權物件文檔與查詢工具 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1424 lines
50 KiB
HTML
1424 lines
50 KiB
HTML
{% extends "_base.html" %}
|
|
|
|
{% block title %}設備歷史績效{% endblock %}
|
|
|
|
{% block head_extra %}
|
|
<script src="/static/js/echarts.min.js"></script>
|
|
|
|
<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;
|
|
--info: #3b82f6;
|
|
--neutral: #64748b;
|
|
}
|
|
|
|
* {
|
|
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: 1800px;
|
|
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;
|
|
}
|
|
|
|
/* Filter Bar */
|
|
.filter-bar {
|
|
background: var(--card-bg);
|
|
padding: 16px 20px;
|
|
border-radius: 10px;
|
|
margin-bottom: 16px;
|
|
box-shadow: var(--shadow);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.filter-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
align-items: center;
|
|
}
|
|
|
|
.filter-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.filter-group label {
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.filter-group input[type="date"],
|
|
.filter-group select {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
background: #fff;
|
|
}
|
|
|
|
.filter-group select {
|
|
min-width: 150px;
|
|
}
|
|
|
|
/* Multi-Select Dropdown */
|
|
.multi-select-container {
|
|
position: relative;
|
|
min-width: 180px;
|
|
}
|
|
|
|
.multi-select-trigger {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.multi-select-trigger:hover {
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.multi-select-text {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.multi-select-arrow {
|
|
font-size: 10px;
|
|
margin-left: 8px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.multi-select-dropdown {
|
|
display: none;
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
margin-top: 4px;
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
z-index: 1000;
|
|
max-height: 300px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.multi-select-dropdown.show {
|
|
display: block;
|
|
}
|
|
|
|
.multi-select-options {
|
|
max-height: 240px;
|
|
overflow-y: auto;
|
|
padding: 8px 0;
|
|
}
|
|
|
|
.multi-select-option {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 6px 12px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.multi-select-option:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.multi-select-option input[type="checkbox"] {
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.multi-select-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
border-top: 1px solid var(--border);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.btn-small {
|
|
padding: 4px 8px;
|
|
font-size: 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn-small:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
/* Granularity Buttons */
|
|
.granularity-btns {
|
|
display: flex;
|
|
gap: 4px;
|
|
background: #f0f2f5;
|
|
padding: 3px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.granularity-btns button {
|
|
padding: 6px 14px;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
border-radius: 4px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.granularity-btns button.active {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.granularity-btns button:hover:not(.active) {
|
|
background: #e0e4e8;
|
|
}
|
|
|
|
/* Checkbox Filter */
|
|
.checkbox-group {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
.checkbox-group label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.checkbox-group input[type="checkbox"] {
|
|
width: 16px;
|
|
height: 16px;
|
|
accent-color: var(--primary);
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 24px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--primary-dark);
|
|
}
|
|
|
|
.btn-primary:disabled {
|
|
background: #a6b0f5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #f0f2f5;
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #e0e4e8;
|
|
}
|
|
|
|
/* KPI Cards */
|
|
.kpi-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, 1fr);
|
|
gap: 14px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.kpi-card {
|
|
background: var(--card-bg);
|
|
border-radius: 10px;
|
|
padding: 18px;
|
|
text-align: center;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.kpi-label {
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.kpi-value {
|
|
font-size: 28px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.kpi-value.green { color: var(--success); }
|
|
.kpi-value.blue { color: var(--primary); }
|
|
.kpi-value.red { color: var(--danger); }
|
|
.kpi-value.yellow { color: var(--warning); }
|
|
.kpi-value.gray { color: var(--neutral); }
|
|
|
|
.kpi-sub {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* Charts Row */
|
|
.charts-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.chart-card {
|
|
background: var(--card-bg);
|
|
border-radius: 10px;
|
|
padding: 16px;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.chart-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
margin-bottom: 12px;
|
|
padding-left: 10px;
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.chart-container {
|
|
height: 280px;
|
|
}
|
|
|
|
/* Table Section */
|
|
.table-section {
|
|
background: var(--card-bg);
|
|
border-radius: 10px;
|
|
padding: 16px;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.table-toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.table-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
padding-left: 10px;
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.table-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* Hierarchical Table */
|
|
.detail-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.detail-table th,
|
|
.detail-table td {
|
|
padding: 10px 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.detail-table th {
|
|
background: #f8f9fb;
|
|
font-weight: 600;
|
|
color: var(--muted);
|
|
position: sticky;
|
|
top: 0;
|
|
}
|
|
|
|
.detail-table tbody tr:hover {
|
|
background: #f8f9fb;
|
|
}
|
|
|
|
/* Hierarchy Levels */
|
|
.row-level-0 {
|
|
background: #f8f9fb;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.row-level-1 {
|
|
background: #fafbfc;
|
|
}
|
|
|
|
.row-level-2 {
|
|
background: #fff;
|
|
}
|
|
|
|
.expand-btn {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
font-size: 12px;
|
|
color: var(--primary);
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.expand-btn.expanded {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.indent-1 { padding-left: 30px !important; }
|
|
.indent-2 { padding-left: 50px !important; }
|
|
|
|
/* Status Colors */
|
|
.status-prd { color: var(--success); }
|
|
.status-sby { color: var(--info); }
|
|
.status-udt { color: var(--danger); }
|
|
.status-sdt { color: var(--warning); }
|
|
.status-egt { color: #8b5cf6; }
|
|
.status-nst { color: var(--neutral); }
|
|
|
|
/* Placeholder */
|
|
.placeholder {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.placeholder-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.placeholder-text {
|
|
font-size: 16px;
|
|
}
|
|
|
|
/* Loading Overlay */
|
|
.loading-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.8);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.loading-overlay.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.loading-spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Table wrapper for scroll */
|
|
.table-wrapper {
|
|
max-height: 500px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1200px) {
|
|
.kpi-row {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
.charts-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.kpi-row {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
.filter-row {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="dashboard">
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<h1>設備歷史績效</h1>
|
|
</div>
|
|
|
|
<!-- Filter Bar -->
|
|
<div class="filter-bar">
|
|
<div class="filter-row">
|
|
<div class="filter-group">
|
|
<label>起始日期</label>
|
|
<input type="date" id="startDate">
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>結束日期</label>
|
|
<input type="date" id="endDate">
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>時間粒度</label>
|
|
<div class="granularity-btns">
|
|
<button data-granularity="day" class="active">日</button>
|
|
<button data-granularity="week">週</button>
|
|
<button data-granularity="month">月</button>
|
|
<button data-granularity="year">年</button>
|
|
</div>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>站點群組</label>
|
|
<div class="multi-select-container" id="workcenterGroupsContainer">
|
|
<div class="multi-select-trigger" id="workcenterGroupsTrigger">
|
|
<span class="multi-select-text">全部站點</span>
|
|
<span class="multi-select-arrow">▼</span>
|
|
</div>
|
|
<div class="multi-select-dropdown" id="workcenterGroupsDropdown">
|
|
<div class="multi-select-options" id="workcenterGroupsOptions">
|
|
</div>
|
|
<div class="multi-select-actions">
|
|
<button type="button" class="btn-small" onclick="selectAllWorkcenterGroups()">全選</button>
|
|
<button type="button" class="btn-small" onclick="clearAllWorkcenterGroups()">清除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>型號</label>
|
|
<div class="multi-select-container" id="familiesContainer">
|
|
<div class="multi-select-trigger" id="familiesTrigger">
|
|
<span class="multi-select-text">全部型號</span>
|
|
<span class="multi-select-arrow">▼</span>
|
|
</div>
|
|
<div class="multi-select-dropdown" id="familiesDropdown">
|
|
<div class="multi-select-options" id="familiesOptions">
|
|
</div>
|
|
<div class="multi-select-actions">
|
|
<button type="button" class="btn-small" onclick="selectAllFamilies()">全選</button>
|
|
<button type="button" class="btn-small" onclick="clearAllFamilies()">清除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="checkbox-group">
|
|
<label><input type="checkbox" id="isProduction"> 生產機</label>
|
|
<label><input type="checkbox" id="isKey"> 關鍵機</label>
|
|
<label><input type="checkbox" id="isMonitor"> 監控機</label>
|
|
</div>
|
|
<button class="btn-primary" id="queryBtn">查詢</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPI Cards -->
|
|
<div class="kpi-row" id="kpiRow">
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">OU%</div>
|
|
<div class="kpi-value green" id="kpiOuPct">--</div>
|
|
<div class="kpi-sub">稼動率</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">PRD 時數</div>
|
|
<div class="kpi-value blue" id="kpiPrdHours">--</div>
|
|
<div class="kpi-sub">生產時間</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">UDT 時數</div>
|
|
<div class="kpi-value red" id="kpiUdtHours">--</div>
|
|
<div class="kpi-sub">非計畫停機</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">SDT 時數</div>
|
|
<div class="kpi-value yellow" id="kpiSdtHours">--</div>
|
|
<div class="kpi-sub">計畫停機</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">EGT 時數</div>
|
|
<div class="kpi-value" style="color: #8b5cf6;" id="kpiEgtHours">--</div>
|
|
<div class="kpi-sub">工程時間</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">機台數</div>
|
|
<div class="kpi-value gray" id="kpiMachineCount">--</div>
|
|
<div class="kpi-sub">不重複機台</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row 1 -->
|
|
<div class="charts-row">
|
|
<div class="chart-card">
|
|
<div class="chart-title">OU% 趨勢</div>
|
|
<div class="chart-container" id="trendChart"></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">E10 狀態分布</div>
|
|
<div class="chart-container" id="stackedChart"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row 2 -->
|
|
<div class="charts-row">
|
|
<div class="chart-card">
|
|
<div class="chart-title">工站 OU% 對比</div>
|
|
<div class="chart-container" id="comparisonChart"></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div class="chart-title">設備狀態熱力圖</div>
|
|
<div class="chart-container" id="heatmapChart"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail Table -->
|
|
<div class="table-section">
|
|
<div class="table-toolbar">
|
|
<div class="table-title">明細資料</div>
|
|
<div class="table-actions">
|
|
<button class="btn-secondary" id="expandAllBtn">全部展開</button>
|
|
<button class="btn-secondary" id="collapseAllBtn">全部收合</button>
|
|
<button class="btn-secondary" id="exportBtn">匯出 CSV</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table class="detail-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 250px;">站點 / 型號 / 機台</th>
|
|
<th>OU%</th>
|
|
<th>PRD</th>
|
|
<th>SBY</th>
|
|
<th>UDT</th>
|
|
<th>SDT</th>
|
|
<th>EGT</th>
|
|
<th>NST</th>
|
|
<th>機台數</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="detailTableBody">
|
|
<tr>
|
|
<td colspan="9">
|
|
<div class="placeholder">
|
|
<div class="placeholder-icon">🔍</div>
|
|
<div class="placeholder-text">請設定查詢條件後點擊「查詢」</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading Overlay -->
|
|
<div class="loading-overlay hidden" id="loadingOverlay">
|
|
<div class="loading-spinner"></div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
(function() {
|
|
// ============================================================
|
|
// State
|
|
// ============================================================
|
|
let currentGranularity = 'day';
|
|
let summaryData = null;
|
|
let detailData = null;
|
|
let hierarchyState = {}; // Track expanded/collapsed state
|
|
let charts = {};
|
|
|
|
// ============================================================
|
|
// DOM Elements
|
|
// ============================================================
|
|
const startDateInput = document.getElementById('startDate');
|
|
const endDateInput = document.getElementById('endDate');
|
|
const workcenterGroupsTrigger = document.getElementById('workcenterGroupsTrigger');
|
|
const workcenterGroupsDropdown = document.getElementById('workcenterGroupsDropdown');
|
|
const workcenterGroupsOptions = document.getElementById('workcenterGroupsOptions');
|
|
const familiesTrigger = document.getElementById('familiesTrigger');
|
|
const familiesDropdown = document.getElementById('familiesDropdown');
|
|
const familiesOptions = document.getElementById('familiesOptions');
|
|
const isProductionCheckbox = document.getElementById('isProduction');
|
|
const isKeyCheckbox = document.getElementById('isKey');
|
|
const isMonitorCheckbox = document.getElementById('isMonitor');
|
|
const queryBtn = document.getElementById('queryBtn');
|
|
const exportBtn = document.getElementById('exportBtn');
|
|
const expandAllBtn = document.getElementById('expandAllBtn');
|
|
const collapseAllBtn = document.getElementById('collapseAllBtn');
|
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
|
|
|
// Selected values for multi-select
|
|
let selectedWorkcenterGroups = [];
|
|
let selectedFamilies = [];
|
|
|
|
// ============================================================
|
|
// Initialization
|
|
// ============================================================
|
|
function init() {
|
|
setDefaultDates();
|
|
loadFilterOptions();
|
|
setupEventListeners();
|
|
initCharts();
|
|
}
|
|
|
|
function setDefaultDates() {
|
|
const today = new Date();
|
|
const endDate = new Date(today);
|
|
endDate.setDate(endDate.getDate() - 1); // Yesterday
|
|
const startDate = new Date(endDate);
|
|
startDate.setDate(startDate.getDate() - 6); // 7 days ago
|
|
|
|
startDateInput.value = formatDate(startDate);
|
|
endDateInput.value = formatDate(endDate);
|
|
}
|
|
|
|
function formatDate(date) {
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
// Granularity buttons
|
|
document.querySelectorAll('.granularity-btns button').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.granularity-btns button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
currentGranularity = btn.dataset.granularity;
|
|
});
|
|
});
|
|
|
|
// Query button
|
|
queryBtn.addEventListener('click', executeQuery);
|
|
|
|
// Export button
|
|
exportBtn.addEventListener('click', exportCsv);
|
|
|
|
// Expand/Collapse buttons
|
|
expandAllBtn.addEventListener('click', () => toggleAllRows(true));
|
|
collapseAllBtn.addEventListener('click', () => toggleAllRows(false));
|
|
}
|
|
|
|
function initCharts() {
|
|
charts.trend = echarts.init(document.getElementById('trendChart'));
|
|
charts.stacked = echarts.init(document.getElementById('stackedChart'));
|
|
charts.comparison = echarts.init(document.getElementById('comparisonChart'));
|
|
charts.heatmap = echarts.init(document.getElementById('heatmapChart'));
|
|
|
|
// Handle window resize
|
|
window.addEventListener('resize', () => {
|
|
Object.values(charts).forEach(chart => chart.resize());
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// API Calls (using MesApi client with timeout and retry)
|
|
// ============================================================
|
|
const API_TIMEOUT = 60000; // 60 seconds timeout
|
|
|
|
async function loadFilterOptions() {
|
|
try {
|
|
const result = await MesApi.get('/api/resource/history/options', {
|
|
timeout: API_TIMEOUT,
|
|
silent: true // Don't show toast for filter options
|
|
});
|
|
if (result.success) {
|
|
populateMultiSelect(workcenterGroupsOptions, result.data.workcenter_groups, 'workcenter');
|
|
populateMultiSelect(familiesOptions, result.data.families.map(f => ({name: f})), 'family');
|
|
setupMultiSelectDropdowns();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load filter options:', error);
|
|
}
|
|
}
|
|
|
|
function populateMultiSelect(container, options, type) {
|
|
container.innerHTML = '';
|
|
options.forEach(opt => {
|
|
const name = opt.name || opt;
|
|
const div = document.createElement('div');
|
|
div.className = 'multi-select-option';
|
|
div.innerHTML = `
|
|
<input type="checkbox" value="${name}" data-type="${type}">
|
|
<span>${name}</span>
|
|
`;
|
|
div.querySelector('input').addEventListener('change', (e) => {
|
|
if (type === 'workcenter') {
|
|
updateSelectedWorkcenterGroups();
|
|
} else {
|
|
updateSelectedFamilies();
|
|
}
|
|
});
|
|
container.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function setupMultiSelectDropdowns() {
|
|
// Workcenter Groups dropdown toggle
|
|
workcenterGroupsTrigger.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
workcenterGroupsDropdown.classList.toggle('show');
|
|
familiesDropdown.classList.remove('show');
|
|
});
|
|
|
|
// Families dropdown toggle
|
|
familiesTrigger.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
familiesDropdown.classList.toggle('show');
|
|
workcenterGroupsDropdown.classList.remove('show');
|
|
});
|
|
|
|
// Close dropdowns when clicking outside
|
|
document.addEventListener('click', () => {
|
|
workcenterGroupsDropdown.classList.remove('show');
|
|
familiesDropdown.classList.remove('show');
|
|
});
|
|
|
|
// Prevent dropdown close when clicking inside
|
|
workcenterGroupsDropdown.addEventListener('click', (e) => e.stopPropagation());
|
|
familiesDropdown.addEventListener('click', (e) => e.stopPropagation());
|
|
}
|
|
|
|
function updateSelectedWorkcenterGroups() {
|
|
const checkboxes = workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]:checked');
|
|
selectedWorkcenterGroups = Array.from(checkboxes).map(cb => cb.value);
|
|
updateMultiSelectText(workcenterGroupsTrigger, selectedWorkcenterGroups, '全部站點');
|
|
}
|
|
|
|
function updateSelectedFamilies() {
|
|
const checkboxes = familiesOptions.querySelectorAll('input[type="checkbox"]:checked');
|
|
selectedFamilies = Array.from(checkboxes).map(cb => cb.value);
|
|
updateMultiSelectText(familiesTrigger, selectedFamilies, '全部型號');
|
|
}
|
|
|
|
function updateMultiSelectText(trigger, selected, defaultText) {
|
|
const textSpan = trigger.querySelector('.multi-select-text');
|
|
if (selected.length === 0) {
|
|
textSpan.textContent = defaultText;
|
|
} else if (selected.length === 1) {
|
|
textSpan.textContent = selected[0];
|
|
} else {
|
|
textSpan.textContent = `已選 ${selected.length} 項`;
|
|
}
|
|
}
|
|
|
|
// Global functions for select all / clear all
|
|
window.selectAllWorkcenterGroups = function() {
|
|
workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
|
|
updateSelectedWorkcenterGroups();
|
|
};
|
|
|
|
window.clearAllWorkcenterGroups = function() {
|
|
workcenterGroupsOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
|
updateSelectedWorkcenterGroups();
|
|
};
|
|
|
|
window.selectAllFamilies = function() {
|
|
familiesOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
|
|
updateSelectedFamilies();
|
|
};
|
|
|
|
window.clearAllFamilies = function() {
|
|
familiesOptions.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
|
updateSelectedFamilies();
|
|
};
|
|
|
|
function buildQueryString() {
|
|
const params = new URLSearchParams();
|
|
params.append('start_date', startDateInput.value);
|
|
params.append('end_date', endDateInput.value);
|
|
params.append('granularity', currentGranularity);
|
|
|
|
// Add multi-select params
|
|
selectedWorkcenterGroups.forEach(g => params.append('workcenter_groups', g));
|
|
selectedFamilies.forEach(f => params.append('families', f));
|
|
|
|
if (isProductionCheckbox.checked) params.append('is_production', '1');
|
|
if (isKeyCheckbox.checked) params.append('is_key', '1');
|
|
if (isMonitorCheckbox.checked) params.append('is_monitor', '1');
|
|
|
|
return params.toString();
|
|
}
|
|
|
|
async function executeQuery() {
|
|
// Validate date range
|
|
const startDate = new Date(startDateInput.value);
|
|
const endDate = new Date(endDateInput.value);
|
|
const diffDays = (endDate - startDate) / (1000 * 60 * 60 * 24);
|
|
|
|
if (diffDays > 730) {
|
|
Toast.warning('查詢範圍不可超過兩年');
|
|
return;
|
|
}
|
|
|
|
if (diffDays < 0) {
|
|
Toast.warning('結束日期必須大於起始日期');
|
|
return;
|
|
}
|
|
|
|
showLoading();
|
|
queryBtn.disabled = true;
|
|
|
|
try {
|
|
const queryString = buildQueryString();
|
|
const summaryUrl = `/api/resource/history/summary?${queryString}`;
|
|
const detailUrl = `/api/resource/history/detail?${queryString}`;
|
|
|
|
// Fetch summary and detail in parallel using MesApi
|
|
const [summaryResult, detailResult] = await Promise.all([
|
|
MesApi.get(summaryUrl, { timeout: API_TIMEOUT }),
|
|
MesApi.get(detailUrl, { timeout: API_TIMEOUT })
|
|
]);
|
|
|
|
if (summaryResult.success) {
|
|
summaryData = summaryResult.data;
|
|
updateKpiCards(summaryData.kpi);
|
|
updateTrendChart(summaryData.trend);
|
|
updateStackedChart(summaryData.trend);
|
|
updateComparisonChart(summaryData.workcenter_comparison);
|
|
updateHeatmapChart(summaryData.heatmap);
|
|
} else {
|
|
Toast.error(summaryResult.error || '查詢摘要失敗');
|
|
}
|
|
|
|
if (detailResult.success) {
|
|
detailData = detailResult.data;
|
|
hierarchyState = {};
|
|
renderDetailTable(detailData);
|
|
|
|
// Show warning if data was truncated
|
|
if (detailResult.truncated) {
|
|
Toast.warning(`明細資料超過 ${detailResult.max_records} 筆,僅顯示前 ${detailResult.max_records} 筆。請使用篩選條件縮小範圍。`);
|
|
}
|
|
} else {
|
|
Toast.error(detailResult.error || '查詢明細失敗');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Query failed:', error);
|
|
Toast.error('查詢失敗: ' + error.message);
|
|
} finally {
|
|
hideLoading();
|
|
queryBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// KPI Cards
|
|
// ============================================================
|
|
function updateKpiCards(kpi) {
|
|
document.getElementById('kpiOuPct').textContent = kpi.ou_pct + '%';
|
|
document.getElementById('kpiPrdHours').textContent = formatHours(kpi.prd_hours);
|
|
document.getElementById('kpiUdtHours').textContent = formatHours(kpi.udt_hours);
|
|
document.getElementById('kpiSdtHours').textContent = formatHours(kpi.sdt_hours);
|
|
document.getElementById('kpiEgtHours').textContent = formatHours(kpi.egt_hours);
|
|
document.getElementById('kpiMachineCount').textContent = kpi.machine_count.toLocaleString();
|
|
}
|
|
|
|
function formatHours(hours) {
|
|
if (hours >= 1000) {
|
|
return (hours / 1000).toFixed(1) + 'K';
|
|
}
|
|
return hours.toLocaleString();
|
|
}
|
|
|
|
// ============================================================
|
|
// Charts
|
|
// ============================================================
|
|
function updateTrendChart(trend) {
|
|
const dates = trend.map(t => t.date);
|
|
const ouPcts = trend.map(t => t.ou_pct);
|
|
|
|
charts.trend.setOption({
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
formatter: function(params) {
|
|
const d = trend[params[0].dataIndex];
|
|
return `${d.date}<br/>
|
|
OU%: <b>${d.ou_pct}%</b><br/>
|
|
PRD: ${d.prd_hours}h<br/>
|
|
SBY: ${d.sby_hours}h<br/>
|
|
UDT: ${d.udt_hours}h`;
|
|
}
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: dates,
|
|
axisLabel: { fontSize: 11 }
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
name: 'OU%',
|
|
max: 100,
|
|
axisLabel: { formatter: '{value}%' }
|
|
},
|
|
series: [{
|
|
data: ouPcts,
|
|
type: 'line',
|
|
smooth: true,
|
|
areaStyle: { opacity: 0.3 },
|
|
itemStyle: { color: '#667eea' },
|
|
lineStyle: { width: 2 }
|
|
}],
|
|
grid: { left: 50, right: 20, top: 30, bottom: 30 }
|
|
});
|
|
}
|
|
|
|
function updateStackedChart(trend) {
|
|
const dates = trend.map(t => t.date);
|
|
|
|
charts.stacked.setOption({
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: { type: 'shadow' },
|
|
formatter: function(params) {
|
|
const idx = params[0].dataIndex;
|
|
const d = trend[idx];
|
|
const total = d.prd_hours + d.sby_hours + d.udt_hours + d.sdt_hours + d.egt_hours + d.nst_hours;
|
|
const pct = (v) => total > 0 ? (v / total * 100).toFixed(1) : 0;
|
|
return `<b>${d.date}</b><br/>
|
|
<span style="color:#22c55e">●</span> PRD: ${d.prd_hours}h (${pct(d.prd_hours)}%)<br/>
|
|
<span style="color:#3b82f6">●</span> SBY: ${d.sby_hours}h (${pct(d.sby_hours)}%)<br/>
|
|
<span style="color:#ef4444">●</span> UDT: ${d.udt_hours}h (${pct(d.udt_hours)}%)<br/>
|
|
<span style="color:#f59e0b">●</span> SDT: ${d.sdt_hours}h (${pct(d.sdt_hours)}%)<br/>
|
|
<span style="color:#8b5cf6">●</span> EGT: ${d.egt_hours}h (${pct(d.egt_hours)}%)<br/>
|
|
<span style="color:#64748b">●</span> NST: ${d.nst_hours}h (${pct(d.nst_hours)}%)<br/>
|
|
<b>Total: ${total.toFixed(1)}h</b>`;
|
|
}
|
|
},
|
|
legend: {
|
|
data: ['PRD', 'SBY', 'UDT', 'SDT', 'EGT', 'NST'],
|
|
bottom: 0,
|
|
textStyle: { fontSize: 10 }
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: dates,
|
|
axisLabel: { fontSize: 10 }
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
name: '時數',
|
|
axisLabel: { formatter: '{value}h' }
|
|
},
|
|
series: [
|
|
{ name: 'PRD', type: 'bar', stack: 'total', data: trend.map(t => t.prd_hours), itemStyle: { color: '#22c55e' } },
|
|
{ name: 'SBY', type: 'bar', stack: 'total', data: trend.map(t => t.sby_hours), itemStyle: { color: '#3b82f6' } },
|
|
{ name: 'UDT', type: 'bar', stack: 'total', data: trend.map(t => t.udt_hours), itemStyle: { color: '#ef4444' } },
|
|
{ name: 'SDT', type: 'bar', stack: 'total', data: trend.map(t => t.sdt_hours), itemStyle: { color: '#f59e0b' } },
|
|
{ name: 'EGT', type: 'bar', stack: 'total', data: trend.map(t => t.egt_hours), itemStyle: { color: '#8b5cf6' } },
|
|
{ name: 'NST', type: 'bar', stack: 'total', data: trend.map(t => t.nst_hours), itemStyle: { color: '#64748b' } }
|
|
],
|
|
grid: { left: 50, right: 20, top: 30, bottom: 60 }
|
|
});
|
|
}
|
|
|
|
function updateComparisonChart(comparison) {
|
|
// Take top 15 workcenters and reverse for bottom-to-top display (highest at top)
|
|
const data = comparison.slice(0, 15).reverse();
|
|
const workcenters = data.map(d => d.workcenter);
|
|
const ouPcts = data.map(d => d.ou_pct);
|
|
|
|
charts.comparison.setOption({
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: { type: 'shadow' },
|
|
formatter: function(params) {
|
|
const d = data[params[0].dataIndex];
|
|
return `${d.workcenter}<br/>OU%: <b>${d.ou_pct}%</b><br/>機台數: ${d.machine_count}`;
|
|
}
|
|
},
|
|
xAxis: {
|
|
type: 'value',
|
|
name: 'OU%',
|
|
max: 100,
|
|
axisLabel: { formatter: '{value}%' }
|
|
},
|
|
yAxis: {
|
|
type: 'category',
|
|
data: workcenters,
|
|
axisLabel: { fontSize: 10 }
|
|
},
|
|
series: [{
|
|
type: 'bar',
|
|
data: ouPcts,
|
|
itemStyle: {
|
|
color: function(params) {
|
|
const val = params.value;
|
|
if (val >= 80) return '#22c55e';
|
|
if (val >= 50) return '#f59e0b';
|
|
return '#ef4444';
|
|
}
|
|
}
|
|
}],
|
|
grid: { left: 100, right: 30, top: 20, bottom: 30 }
|
|
});
|
|
}
|
|
|
|
function updateHeatmapChart(heatmap) {
|
|
if (!heatmap || heatmap.length === 0) {
|
|
charts.heatmap.clear();
|
|
return;
|
|
}
|
|
|
|
// Get unique workcenters and dates
|
|
const workcenters = [...new Set(heatmap.map(h => h.workcenter))];
|
|
const dates = [...new Set(heatmap.map(h => h.date))].sort();
|
|
|
|
// Build data matrix
|
|
const data = heatmap.map(h => [
|
|
dates.indexOf(h.date),
|
|
workcenters.indexOf(h.workcenter),
|
|
h.ou_pct
|
|
]);
|
|
|
|
charts.heatmap.setOption({
|
|
tooltip: {
|
|
position: 'top',
|
|
formatter: function(params) {
|
|
return `${workcenters[params.value[1]]}<br/>${dates[params.value[0]]}<br/>OU%: <b>${params.value[2]}%</b>`;
|
|
}
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: dates,
|
|
splitArea: { show: true },
|
|
axisLabel: { fontSize: 9, rotate: 45 }
|
|
},
|
|
yAxis: {
|
|
type: 'category',
|
|
data: workcenters,
|
|
splitArea: { show: true },
|
|
axisLabel: { fontSize: 9 }
|
|
},
|
|
visualMap: {
|
|
min: 0,
|
|
max: 100,
|
|
calculable: true,
|
|
orient: 'horizontal',
|
|
left: 'center',
|
|
bottom: 0,
|
|
inRange: {
|
|
color: ['#ef4444', '#f59e0b', '#22c55e']
|
|
}
|
|
},
|
|
series: [{
|
|
type: 'heatmap',
|
|
data: data,
|
|
label: { show: false },
|
|
emphasis: {
|
|
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
|
}
|
|
}],
|
|
grid: { left: 100, right: 20, top: 10, bottom: 60 }
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Hierarchical Table
|
|
// ============================================================
|
|
function renderDetailTable(data) {
|
|
const tbody = document.getElementById('detailTableBody');
|
|
|
|
if (!data || data.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="9">
|
|
<div class="placeholder">
|
|
<div class="placeholder-icon">🔍</div>
|
|
<div class="placeholder-text">無符合條件的資料</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Build hierarchy
|
|
const hierarchy = buildHierarchy(data);
|
|
|
|
// Render rows
|
|
tbody.innerHTML = '';
|
|
hierarchy.forEach(wc => {
|
|
// Workcenter level
|
|
const wcRow = createRow(wc, 0, `wc_${wc.workcenter}`);
|
|
tbody.appendChild(wcRow);
|
|
|
|
// Family level
|
|
if (hierarchyState[`wc_${wc.workcenter}`]) {
|
|
wc.families.forEach(fam => {
|
|
const famRow = createRow(fam, 1, `fam_${wc.workcenter}_${fam.family}`);
|
|
famRow.dataset.parent = `wc_${wc.workcenter}`;
|
|
tbody.appendChild(famRow);
|
|
|
|
// Resource level
|
|
if (hierarchyState[`fam_${wc.workcenter}_${fam.family}`]) {
|
|
fam.resources.forEach(res => {
|
|
const resRow = createRow(res, 2);
|
|
resRow.dataset.parent = `fam_${wc.workcenter}_${fam.family}`;
|
|
tbody.appendChild(resRow);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildHierarchy(data) {
|
|
const wcMap = {};
|
|
|
|
data.forEach(item => {
|
|
const wc = item.workcenter;
|
|
const fam = item.family;
|
|
|
|
if (!wcMap[wc]) {
|
|
wcMap[wc] = {
|
|
workcenter: wc,
|
|
name: wc,
|
|
families: [],
|
|
familyMap: {},
|
|
ou_pct: 0, prd_hours: 0, prd_pct: 0,
|
|
sby_hours: 0, sby_pct: 0, udt_hours: 0, udt_pct: 0,
|
|
sdt_hours: 0, sdt_pct: 0, egt_hours: 0, egt_pct: 0,
|
|
nst_hours: 0, nst_pct: 0, machine_count: 0
|
|
};
|
|
}
|
|
|
|
if (!wcMap[wc].familyMap[fam]) {
|
|
wcMap[wc].familyMap[fam] = {
|
|
family: fam,
|
|
name: fam,
|
|
resources: [],
|
|
ou_pct: 0, prd_hours: 0, prd_pct: 0,
|
|
sby_hours: 0, sby_pct: 0, udt_hours: 0, udt_pct: 0,
|
|
sdt_hours: 0, sdt_pct: 0, egt_hours: 0, egt_pct: 0,
|
|
nst_hours: 0, nst_pct: 0, machine_count: 0
|
|
};
|
|
wcMap[wc].families.push(wcMap[wc].familyMap[fam]);
|
|
}
|
|
|
|
// Add resource
|
|
wcMap[wc].familyMap[fam].resources.push({
|
|
name: item.resource,
|
|
...item
|
|
});
|
|
|
|
// Aggregate to family
|
|
const famObj = wcMap[wc].familyMap[fam];
|
|
famObj.prd_hours += item.prd_hours;
|
|
famObj.sby_hours += item.sby_hours;
|
|
famObj.udt_hours += item.udt_hours;
|
|
famObj.sdt_hours += item.sdt_hours;
|
|
famObj.egt_hours += item.egt_hours;
|
|
famObj.nst_hours += item.nst_hours;
|
|
famObj.machine_count += 1;
|
|
|
|
// Aggregate to workcenter
|
|
wcMap[wc].prd_hours += item.prd_hours;
|
|
wcMap[wc].sby_hours += item.sby_hours;
|
|
wcMap[wc].udt_hours += item.udt_hours;
|
|
wcMap[wc].sdt_hours += item.sdt_hours;
|
|
wcMap[wc].egt_hours += item.egt_hours;
|
|
wcMap[wc].nst_hours += item.nst_hours;
|
|
wcMap[wc].machine_count += 1;
|
|
});
|
|
|
|
// Calculate OU% and percentages
|
|
Object.values(wcMap).forEach(wc => {
|
|
calcPercentages(wc);
|
|
wc.families.forEach(fam => {
|
|
calcPercentages(fam);
|
|
});
|
|
});
|
|
|
|
return Object.values(wcMap).sort((a, b) => b.ou_pct - a.ou_pct);
|
|
}
|
|
|
|
function calcPercentages(obj) {
|
|
const total = obj.prd_hours + obj.sby_hours + obj.udt_hours + obj.sdt_hours + obj.egt_hours + obj.nst_hours;
|
|
const denom = obj.prd_hours + obj.sby_hours + obj.udt_hours + obj.sdt_hours + obj.egt_hours;
|
|
|
|
obj.ou_pct = denom > 0 ? Math.round(obj.prd_hours / denom * 1000) / 10 : 0;
|
|
obj.prd_pct = total > 0 ? Math.round(obj.prd_hours / total * 1000) / 10 : 0;
|
|
obj.sby_pct = total > 0 ? Math.round(obj.sby_hours / total * 1000) / 10 : 0;
|
|
obj.udt_pct = total > 0 ? Math.round(obj.udt_hours / total * 1000) / 10 : 0;
|
|
obj.sdt_pct = total > 0 ? Math.round(obj.sdt_hours / total * 1000) / 10 : 0;
|
|
obj.egt_pct = total > 0 ? Math.round(obj.egt_hours / total * 1000) / 10 : 0;
|
|
obj.nst_pct = total > 0 ? Math.round(obj.nst_hours / total * 1000) / 10 : 0;
|
|
}
|
|
|
|
function createRow(item, level, rowId) {
|
|
const tr = document.createElement('tr');
|
|
tr.className = `row-level-${level}`;
|
|
if (rowId) tr.dataset.rowId = rowId;
|
|
|
|
const indentClass = level > 0 ? `indent-${level}` : '';
|
|
const hasChildren = level < 2 && (item.families?.length > 0 || item.resources?.length > 0);
|
|
const isExpanded = rowId ? hierarchyState[rowId] : false;
|
|
|
|
const expandBtn = hasChildren
|
|
? `<button class="expand-btn ${isExpanded ? 'expanded' : ''}" onclick="toggleRow('${rowId}')">▶</button>`
|
|
: '<span style="display:inline-block;width:24px;"></span>';
|
|
|
|
tr.innerHTML = `
|
|
<td class="${indentClass}">${expandBtn}${item.name}</td>
|
|
<td><b>${item.ou_pct}%</b></td>
|
|
<td class="status-prd">${formatHoursPct(item.prd_hours, item.prd_pct)}</td>
|
|
<td class="status-sby">${formatHoursPct(item.sby_hours, item.sby_pct)}</td>
|
|
<td class="status-udt">${formatHoursPct(item.udt_hours, item.udt_pct)}</td>
|
|
<td class="status-sdt">${formatHoursPct(item.sdt_hours, item.sdt_pct)}</td>
|
|
<td class="status-egt">${formatHoursPct(item.egt_hours, item.egt_pct)}</td>
|
|
<td class="status-nst">${formatHoursPct(item.nst_hours, item.nst_pct)}</td>
|
|
<td>${item.machine_count}</td>
|
|
`;
|
|
|
|
return tr;
|
|
}
|
|
|
|
function formatHoursPct(hours, pct) {
|
|
return `${Math.round(hours * 10) / 10}h (${pct}%)`;
|
|
}
|
|
|
|
// Make toggleRow global
|
|
window.toggleRow = function(rowId) {
|
|
hierarchyState[rowId] = !hierarchyState[rowId];
|
|
renderDetailTable(detailData);
|
|
};
|
|
|
|
function toggleAllRows(expand) {
|
|
if (!detailData) return;
|
|
|
|
const hierarchy = buildHierarchy(detailData);
|
|
hierarchy.forEach(wc => {
|
|
hierarchyState[`wc_${wc.workcenter}`] = expand;
|
|
wc.families.forEach(fam => {
|
|
hierarchyState[`fam_${wc.workcenter}_${fam.family}`] = expand;
|
|
});
|
|
});
|
|
renderDetailTable(detailData);
|
|
}
|
|
|
|
// ============================================================
|
|
// Export
|
|
// ============================================================
|
|
function exportCsv() {
|
|
if (!startDateInput.value || !endDateInput.value) {
|
|
Toast.warning('請先設定查詢條件');
|
|
return;
|
|
}
|
|
|
|
const queryString = buildQueryString();
|
|
const url = `/api/resource/history/export?${queryString}`;
|
|
|
|
// Create download link
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `resource_history_${startDateInput.value}_to_${endDateInput.value}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
|
|
Toast.success('CSV 匯出中...');
|
|
}
|
|
|
|
// ============================================================
|
|
// Loading
|
|
// ============================================================
|
|
function showLoading() {
|
|
loadingOverlay.classList.remove('hidden');
|
|
}
|
|
|
|
function hideLoading() {
|
|
loadingOverlay.classList.add('hidden');
|
|
}
|
|
|
|
// ============================================================
|
|
// Start
|
|
// ============================================================
|
|
init();
|
|
})();
|
|
</script>
|
|
{% endblock %}
|