Files
DashBoard/src/mes_dashboard/templates/resource_history.html
beabigegg a787436115 feat: 新增 Resource Cache 模組與表名更新
- 新增 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>
2026-01-29 15:08:16 +08:00

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">&#128269;</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">&#128269;</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}')">&#9654;</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 %}