Add interdependent filter controls where upstream filters (workcenter group, boolean flags) dynamically narrow downstream options (family, machine). MultiSelect component moved to resource-shared with searchable support. Backend endpoints accept families and resource_ids params, leveraging existing Redis-cached resource metadata for client-side cascade filtering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
497 lines
13 KiB
Vue
497 lines
13 KiB
Vue
<script setup>
|
|
import { computed, reactive, ref } from 'vue';
|
|
|
|
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
|
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
|
import { MATRIX_STATUS_COLUMNS, STATUS_DISPLAY_MAP, normalizeStatus } from '../resource-shared/constants.js';
|
|
|
|
import EquipmentGrid from './components/EquipmentGrid.vue';
|
|
import FilterBar from './components/FilterBar.vue';
|
|
import FloatingTooltip from './components/FloatingTooltip.vue';
|
|
import MatrixSection from './components/MatrixSection.vue';
|
|
import StatusHeader from './components/StatusHeader.vue';
|
|
import SummaryCards from './components/SummaryCards.vue';
|
|
|
|
ensureMesApiAvailable();
|
|
|
|
const API_TIMEOUT = 60000;
|
|
|
|
const allEquipment = ref([]);
|
|
const workcenterGroups = ref([]);
|
|
const allResources = ref([]);
|
|
const summary = ref({
|
|
totalCount: 0,
|
|
byStatus: {
|
|
PRD: 0,
|
|
SBY: 0,
|
|
UDT: 0,
|
|
SDT: 0,
|
|
EGT: 0,
|
|
NST: 0,
|
|
OTHER: 0,
|
|
},
|
|
ouPct: 0,
|
|
availabilityPct: 0,
|
|
});
|
|
|
|
const filterState = reactive({
|
|
group: '',
|
|
isProduction: false,
|
|
isKey: false,
|
|
isMonitor: false,
|
|
families: [],
|
|
machines: [],
|
|
});
|
|
|
|
const matrixFilter = ref([]);
|
|
const summaryStatusFilter = ref(null);
|
|
const hierarchyState = reactive({});
|
|
|
|
const loading = reactive({
|
|
initial: true,
|
|
refreshing: false,
|
|
options: false,
|
|
});
|
|
|
|
const cacheLevel = ref('loading');
|
|
const cacheText = ref('檢查中...');
|
|
const lastUpdate = ref('--');
|
|
|
|
const summaryError = ref('');
|
|
const equipmentError = ref('');
|
|
|
|
const tooltipState = reactive({
|
|
visible: false,
|
|
type: 'lot',
|
|
payload: null,
|
|
position: { x: 0, y: 0 },
|
|
});
|
|
|
|
function unwrapApiResult(result, fallbackMessage) {
|
|
if (result?.success === true) {
|
|
return result.data;
|
|
}
|
|
if (result?.success === false) {
|
|
throw new Error(result.error || fallbackMessage);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function buildFilterParams() {
|
|
const params = {};
|
|
|
|
if (filterState.group) {
|
|
params.workcenter_groups = filterState.group;
|
|
}
|
|
if (filterState.isProduction) {
|
|
params.is_production = 1;
|
|
}
|
|
if (filterState.isKey) {
|
|
params.is_key = 1;
|
|
}
|
|
if (filterState.isMonitor) {
|
|
params.is_monitor = 1;
|
|
}
|
|
if (filterState.families.length) {
|
|
params.families = filterState.families.join(',');
|
|
}
|
|
if (filterState.machines.length) {
|
|
params.resource_ids = filterState.machines.join(',');
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
// --- Cascade: derive available family/machine options from upstream filters ---
|
|
const filteredByUpstream = computed(() => {
|
|
return allResources.value.filter((r) => {
|
|
if (filterState.group && r.workcenterGroup !== filterState.group) return false;
|
|
if (filterState.isProduction && !r.isProduction) return false;
|
|
if (filterState.isKey && !r.isKey) return false;
|
|
if (filterState.isMonitor && !r.isMonitor) return false;
|
|
return true;
|
|
});
|
|
});
|
|
|
|
const familyOptions = computed(() => {
|
|
const set = new Set();
|
|
filteredByUpstream.value.forEach((r) => {
|
|
if (r.family) set.add(r.family);
|
|
});
|
|
return [...set].sort();
|
|
});
|
|
|
|
const machineOptions = computed(() => {
|
|
let list = filteredByUpstream.value;
|
|
if (filterState.families.length > 0) {
|
|
const fset = new Set(filterState.families);
|
|
list = list.filter((r) => fset.has(r.family));
|
|
}
|
|
return list
|
|
.map((r) => ({ label: r.name, value: r.id }))
|
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
});
|
|
|
|
function pruneInvalidSelections() {
|
|
const validFamilies = new Set(familyOptions.value);
|
|
filterState.families = filterState.families.filter((f) => validFamilies.has(f));
|
|
const validMachineIds = new Set(machineOptions.value.map((m) => m.value));
|
|
filterState.machines = filterState.machines.filter((m) => validMachineIds.has(m));
|
|
}
|
|
|
|
function resetHierarchyState() {
|
|
Object.keys(hierarchyState).forEach((key) => {
|
|
delete hierarchyState[key];
|
|
});
|
|
}
|
|
|
|
async function loadOptions() {
|
|
loading.options = true;
|
|
|
|
try {
|
|
const result = await apiGet('/api/resource/status/options', {
|
|
timeout: API_TIMEOUT,
|
|
silent: true,
|
|
});
|
|
const data = unwrapApiResult(result, '載入篩選選項失敗');
|
|
workcenterGroups.value = Array.isArray(data?.workcenter_groups) ? data.workcenter_groups : [];
|
|
allResources.value = Array.isArray(data?.resources) ? data.resources : [];
|
|
} finally {
|
|
loading.options = false;
|
|
}
|
|
}
|
|
|
|
async function loadSummary() {
|
|
const result = await apiGet('/api/resource/status/summary', {
|
|
params: buildFilterParams(),
|
|
timeout: API_TIMEOUT,
|
|
silent: true,
|
|
});
|
|
|
|
const data = unwrapApiResult(result, '載入摘要失敗');
|
|
const byStatus = data?.by_status || {};
|
|
|
|
const normalizedStatus = Object.fromEntries(
|
|
MATRIX_STATUS_COLUMNS.map((status) => [status, Number(byStatus[status] || 0)])
|
|
);
|
|
|
|
summary.value = {
|
|
totalCount: Number(data?.total_count || 0),
|
|
byStatus: normalizedStatus,
|
|
ouPct: Number(data?.ou_pct || 0),
|
|
availabilityPct: Number(data?.availability_pct || 0),
|
|
};
|
|
}
|
|
|
|
async function loadEquipment() {
|
|
const result = await apiGet('/api/resource/status', {
|
|
params: buildFilterParams(),
|
|
timeout: API_TIMEOUT,
|
|
silent: true,
|
|
});
|
|
|
|
const data = unwrapApiResult(result, '載入設備資料失敗');
|
|
|
|
allEquipment.value = Array.isArray(data) ? data : [];
|
|
matrixFilter.value = [];
|
|
summaryStatusFilter.value = null;
|
|
resetHierarchyState();
|
|
}
|
|
|
|
async function checkCacheStatus() {
|
|
try {
|
|
const health = await apiGet('/health', {
|
|
timeout: 15000,
|
|
retries: 0,
|
|
silent: true,
|
|
});
|
|
|
|
const resourceCache = health?.resource_cache || {};
|
|
const equipmentCache = health?.equipment_status_cache || {};
|
|
|
|
if (resourceCache.enabled && resourceCache.loaded) {
|
|
cacheLevel.value = 'ok';
|
|
cacheText.value = `快取正常 (${Number(resourceCache.count || 0)} 筆)`;
|
|
} else if (resourceCache.enabled) {
|
|
cacheLevel.value = 'loading';
|
|
cacheText.value = '快取載入中...';
|
|
} else {
|
|
cacheLevel.value = 'error';
|
|
cacheText.value = '快取未啟用';
|
|
}
|
|
|
|
if (equipmentCache.updated_at) {
|
|
lastUpdate.value = new Date(equipmentCache.updated_at).toLocaleString('zh-TW');
|
|
} else {
|
|
lastUpdate.value = '--';
|
|
}
|
|
} catch {
|
|
cacheLevel.value = 'error';
|
|
cacheText.value = '無法連線';
|
|
lastUpdate.value = '--';
|
|
}
|
|
}
|
|
|
|
function buildSingleFilterLabel(filter) {
|
|
const parts = [filter.workcenter_group];
|
|
if (filter.family) {
|
|
parts.push(filter.family);
|
|
}
|
|
if (filter.resource) {
|
|
const resource = allEquipment.value.find((item) => item.RESOURCEID === filter.resource);
|
|
parts.push(resource?.RESOURCENAME || filter.resource);
|
|
}
|
|
parts.push(STATUS_DISPLAY_MAP[filter.status] || filter.status);
|
|
return parts.join(' / ');
|
|
}
|
|
|
|
function filterKey(f) {
|
|
return `${f.workcenter_group}|${f.status}|${f.family || ''}|${f.resource || ''}`;
|
|
}
|
|
|
|
function matchSingleFilter(eq, filter) {
|
|
if ((eq.WORKCENTER_GROUP || 'UNKNOWN') !== filter.workcenter_group) {
|
|
return false;
|
|
}
|
|
if (filter.family && (eq.RESOURCEFAMILYNAME || 'UNKNOWN') !== filter.family) {
|
|
return false;
|
|
}
|
|
if (filter.resource && (eq.RESOURCEID || null) !== filter.resource) {
|
|
return false;
|
|
}
|
|
return normalizeStatus(eq.EQUIPMENTASSETSSTATUS) === filter.status;
|
|
}
|
|
|
|
const displayedEquipment = computed(() => {
|
|
const filters = matrixFilter.value;
|
|
return allEquipment.value.filter((eq) => {
|
|
if (filters.length > 0 && !filters.some((f) => matchSingleFilter(eq, f))) {
|
|
return false;
|
|
}
|
|
if (summaryStatusFilter.value && normalizeStatus(eq.EQUIPMENTASSETSSTATUS) !== summaryStatusFilter.value) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
});
|
|
|
|
const activeFilterText = computed(() => {
|
|
const labels = [];
|
|
|
|
if (matrixFilter.value.length > 0) {
|
|
const parts = matrixFilter.value.map(buildSingleFilterLabel);
|
|
labels.push(`矩陣篩選: ${parts.join(' + ')}`);
|
|
}
|
|
|
|
if (summaryStatusFilter.value) {
|
|
labels.push(`卡片篩選: ${STATUS_DISPLAY_MAP[summaryStatusFilter.value] || summaryStatusFilter.value}`);
|
|
}
|
|
|
|
return labels.join(' | ');
|
|
});
|
|
|
|
function applyMatrixFilter(nextFilter) {
|
|
const entry = {
|
|
workcenter_group: nextFilter.workcenter_group,
|
|
status: nextFilter.status,
|
|
family: nextFilter.family || null,
|
|
resource: nextFilter.resource || null,
|
|
};
|
|
const key = filterKey(entry);
|
|
const idx = matrixFilter.value.findIndex((f) => filterKey(f) === key);
|
|
if (idx >= 0) {
|
|
matrixFilter.value = matrixFilter.value.filter((_, i) => i !== idx);
|
|
} else {
|
|
matrixFilter.value = [...matrixFilter.value, entry];
|
|
}
|
|
}
|
|
|
|
function clearAllEquipmentFilters() {
|
|
matrixFilter.value = [];
|
|
summaryStatusFilter.value = null;
|
|
}
|
|
|
|
function toggleSummaryStatus(status) {
|
|
summaryStatusFilter.value = summaryStatusFilter.value === status ? null : status;
|
|
}
|
|
|
|
function handleToggleRow(rowId) {
|
|
hierarchyState[rowId] = !hierarchyState[rowId];
|
|
}
|
|
|
|
function handleToggleAllRows({ expand, rowIds }) {
|
|
(rowIds || []).forEach((rowId) => {
|
|
hierarchyState[rowId] = Boolean(expand);
|
|
});
|
|
}
|
|
|
|
function closeTooltip() {
|
|
tooltipState.visible = false;
|
|
tooltipState.payload = null;
|
|
}
|
|
|
|
function openLotTooltip({ x, y, equipment }) {
|
|
const lotDetails = equipment?.LOT_DETAILS;
|
|
if (!Array.isArray(lotDetails) || lotDetails.length === 0) {
|
|
return;
|
|
}
|
|
|
|
tooltipState.type = 'lot';
|
|
tooltipState.payload = lotDetails;
|
|
tooltipState.position = { x, y };
|
|
tooltipState.visible = true;
|
|
}
|
|
|
|
function openJobTooltip({ x, y, equipment }) {
|
|
if (!equipment?.JOBORDER) {
|
|
return;
|
|
}
|
|
|
|
tooltipState.type = 'job';
|
|
tooltipState.payload = equipment;
|
|
tooltipState.position = { x, y };
|
|
tooltipState.visible = true;
|
|
}
|
|
|
|
async function loadData(showOverlay = false) {
|
|
if (showOverlay) {
|
|
loading.initial = true;
|
|
}
|
|
|
|
loading.refreshing = true;
|
|
summaryError.value = '';
|
|
equipmentError.value = '';
|
|
|
|
const [summaryResult, equipmentResult] = await Promise.allSettled([loadSummary(), loadEquipment()]);
|
|
await checkCacheStatus();
|
|
|
|
if (summaryResult.status === 'rejected') {
|
|
summaryError.value = summaryResult.reason?.message || '摘要資料載入失敗';
|
|
}
|
|
|
|
if (equipmentResult.status === 'rejected') {
|
|
equipmentError.value = equipmentResult.reason?.message || '設備資料載入失敗';
|
|
allEquipment.value = [];
|
|
}
|
|
|
|
loading.refreshing = false;
|
|
loading.initial = false;
|
|
}
|
|
|
|
async function applyFiltersAndReload() {
|
|
closeTooltip();
|
|
await loadData(false);
|
|
resetAutoRefresh();
|
|
}
|
|
|
|
function updateGroup(group) {
|
|
filterState.group = group || '';
|
|
pruneInvalidSelections();
|
|
void applyFiltersAndReload();
|
|
}
|
|
|
|
function updateFlags(nextFlags) {
|
|
filterState.isProduction = Boolean(nextFlags?.isProduction);
|
|
filterState.isKey = Boolean(nextFlags?.isKey);
|
|
filterState.isMonitor = Boolean(nextFlags?.isMonitor);
|
|
pruneInvalidSelections();
|
|
void applyFiltersAndReload();
|
|
}
|
|
|
|
function updateFamilies(families) {
|
|
filterState.families = families || [];
|
|
pruneInvalidSelections();
|
|
void applyFiltersAndReload();
|
|
}
|
|
|
|
function updateMachines(machines) {
|
|
filterState.machines = machines || [];
|
|
void applyFiltersAndReload();
|
|
}
|
|
|
|
const { resetAutoRefresh, triggerRefresh } = useAutoRefresh({
|
|
onRefresh: () => loadData(false),
|
|
intervalMs: 5 * 60 * 1000,
|
|
autoStart: true,
|
|
refreshOnVisible: true,
|
|
});
|
|
|
|
async function handleManualRefresh() {
|
|
closeTooltip();
|
|
await triggerRefresh({ force: true, resetTimer: true });
|
|
}
|
|
|
|
async function initPage() {
|
|
try {
|
|
await loadOptions();
|
|
} catch (error) {
|
|
equipmentError.value = error?.message || '載入篩選選項失敗';
|
|
}
|
|
await loadData(true);
|
|
}
|
|
|
|
void initPage();
|
|
</script>
|
|
|
|
<template>
|
|
<div class="resource-page">
|
|
<div class="dashboard">
|
|
<StatusHeader
|
|
:cache-level="cacheLevel"
|
|
:cache-text="cacheText"
|
|
:last-update="lastUpdate"
|
|
:refreshing="loading.refreshing"
|
|
@refresh="handleManualRefresh"
|
|
/>
|
|
|
|
<FilterBar
|
|
:workcenter-groups="workcenterGroups"
|
|
:selected-group="filterState.group"
|
|
:flags="filterState"
|
|
:family-options="familyOptions"
|
|
:machine-options="machineOptions"
|
|
:selected-families="filterState.families"
|
|
:selected-machines="filterState.machines"
|
|
:loading="loading.options || loading.refreshing"
|
|
@change-group="updateGroup"
|
|
@change-flags="updateFlags"
|
|
@change-families="updateFamilies"
|
|
@change-machines="updateMachines"
|
|
/>
|
|
|
|
<p v-if="summaryError" class="error-banner">{{ summaryError }}</p>
|
|
<SummaryCards :summary="summary" :active-status="summaryStatusFilter" @toggle-status="toggleSummaryStatus" />
|
|
|
|
<p v-if="equipmentError" class="error-banner">{{ equipmentError }}</p>
|
|
<MatrixSection
|
|
:equipment="allEquipment"
|
|
:expanded-state="hierarchyState"
|
|
:matrix-filter="matrixFilter"
|
|
@toggle-row="handleToggleRow"
|
|
@toggle-all="handleToggleAllRows"
|
|
@cell-filter="applyMatrixFilter"
|
|
/>
|
|
|
|
<EquipmentGrid
|
|
:equipment="displayedEquipment"
|
|
:active-filter-text="activeFilterText"
|
|
@clear-filter="clearAllEquipmentFilters"
|
|
@show-lot="openLotTooltip"
|
|
@show-job="openJobTooltip"
|
|
/>
|
|
</div>
|
|
|
|
<div class="loading-overlay" :class="{ hidden: !loading.initial }">
|
|
<div class="loading-spinner"></div>
|
|
</div>
|
|
|
|
<FloatingTooltip
|
|
:visible="tooltipState.visible"
|
|
:type="tooltipState.type"
|
|
:payload="tooltipState.payload"
|
|
:position="tooltipState.position"
|
|
@close="closeTooltip"
|
|
/>
|
|
</div>
|
|
</template>
|