diff --git a/data/modernization_feature_flags.json b/data/modernization_feature_flags.json index 49f66a9..c01088d 100644 --- a/data/modernization_feature_flags.json +++ b/data/modernization_feature_flags.json @@ -6,6 +6,7 @@ "/hold-overview": {"content_cutover_enabled": true}, "/hold-detail": {"content_cutover_enabled": true}, "/hold-history": {"content_cutover_enabled": true}, + "/reject-history": {"content_cutover_enabled": false}, "/resource": {"content_cutover_enabled": true}, "/resource-history": {"content_cutover_enabled": true}, "/qc-gate": {"content_cutover_enabled": true}, diff --git a/frontend/package.json b/frontend/package.json index 7385b25..0465e5d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite --host", - "build": "vite build && cp ../src/mes_dashboard/static/dist/src/portal-shell/index.html ../src/mes_dashboard/static/dist/portal-shell.html && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html", + "build": "vite build && cp ../src/mes_dashboard/static/dist/src/portal-shell/index.html ../src/mes_dashboard/static/dist/portal-shell.html && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.html && cp ../src/mes_dashboard/static/dist/src/reject-history/index.html ../src/mes_dashboard/static/dist/reject-history.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html", "test": "node --test tests/*.test.js" }, "devDependencies": { diff --git a/frontend/src/query-tool/App.vue b/frontend/src/query-tool/App.vue index 62016fe..bf238ba 100644 --- a/frontend/src/query-tool/App.vue +++ b/frontend/src/query-tool/App.vue @@ -192,17 +192,11 @@ async function handleResolveLots() { return; } + // Build tree only — don't auto-select or load detail data. + // User clicks a node to trigger detail/timeline loading on demand. await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value); - - const treeRootIds = lotLineage.treeRoots.value; - if (treeRootIds.length === 0) { - await lotDetail.setSelectedContainerId(''); - lotLineage.clearSelection(); - return; - } - - // Auto-select all tree roots and load detail for their full subtrees - await handleSelectNodes(treeRootIds); + lotLineage.clearSelection(); + lotDetail.clearTabData(); } async function handleSelectNodes(containerIds) { diff --git a/frontend/src/query-tool/components/LineageTreeChart.vue b/frontend/src/query-tool/components/LineageTreeChart.vue index 3464065..2d4e284 100644 --- a/frontend/src/query-tool/components/LineageTreeChart.vue +++ b/frontend/src/query-tool/components/LineageTreeChart.vue @@ -145,113 +145,137 @@ function buildNode(cid, visited) { }; } -const echartsData = computed(() => { +// Build each root into its own independent tree data +const treesData = computed(() => { if (props.treeRoots.length === 0) { return []; } - const visited = new Set(); + const globalVisited = new Set(); return props.treeRoots - .map((rootId) => buildNode(rootId, visited)) + .map((rootId) => buildNode(rootId, globalVisited)) .filter(Boolean); }); -const hasData = computed(() => echartsData.value.length > 0); +const hasData = computed(() => treesData.value.length > 0); -function countNodes(nodes) { - let count = 0; - function walk(list) { - list.forEach((node) => { - count += 1; - if (node.children?.length > 0) { - walk(node.children); - } - }); +function countLeaves(node) { + if (!node.children || node.children.length === 0) { + return 1; } - walk(nodes); - return count; + return node.children.reduce((sum, child) => sum + countLeaves(child), 0); } const chartHeight = computed(() => { - const total = countNodes(echartsData.value); - const base = Math.max(300, Math.min(800, total * 28)); + const totalLeaves = treesData.value.reduce((sum, tree) => sum + countLeaves(tree), 0); + const base = Math.max(300, Math.min(1200, totalLeaves * 36)); return `${base}px`; }); +const TREE_SERIES_DEFAULTS = Object.freeze({ + type: 'tree', + layout: 'orthogonal', + orient: 'LR', + expandAndCollapse: true, + initialTreeDepth: -1, + roam: 'move', + symbol: 'circle', + symbolSize: 10, + label: { + show: true, + position: 'right', + fontSize: 11, + color: '#334155', + overflow: 'truncate', + ellipsis: '…', + width: 160, + }, + lineStyle: { + width: 1.5, + color: '#CBD5E1', + curveness: 0.5, + }, + emphasis: { + focus: 'ancestor', + itemStyle: { borderWidth: 2 }, + label: { fontWeight: 'bold' }, + }, + animationDuration: 350, + animationDurationUpdate: 300, +}); + const chartOption = computed(() => { - if (!hasData.value) { + const trees = treesData.value; + if (trees.length === 0) { return null; } - return { - tooltip: { - trigger: 'item', - triggerOn: 'mousemove', - formatter(params) { - const data = params?.data; - if (!data) { - return ''; - } - const val = data.value || {}; - const lines = [`${data.name}`]; - if (val.type === 'serial') { - lines.push('成品序列號'); - } else if (val.type === 'root') { - lines.push('根節點(晶批)'); - } else if (val.type === 'leaf') { - lines.push('末端節點'); - } else if (val.type === 'branch') { - lines.push('中間節點'); - } - if (val.cid && val.cid !== data.name) { - lines.push(`CID: ${val.cid}`); - } - return lines.join('
'); - }, + const tooltip = { + trigger: 'item', + triggerOn: 'mousemove', + formatter(params) { + const data = params?.data; + if (!data) { + return ''; + } + const val = data.value || {}; + const lines = [`${data.name}`]; + if (val.type === 'serial') { + lines.push('成品序列號'); + } else if (val.type === 'root') { + lines.push('根節點(晶批)'); + } else if (val.type === 'leaf') { + lines.push('末端節點'); + } else if (val.type === 'branch') { + lines.push('中間節點'); + } + if (val.cid && val.cid !== data.name) { + lines.push(`CID: ${val.cid}`); + } + return lines.join('
'); }, - series: [ - { - type: 'tree', - layout: 'orthogonal', - orient: 'LR', - expandAndCollapse: true, - initialTreeDepth: -1, - roam: 'move', - symbol: 'circle', - symbolSize: 10, - label: { - show: true, - position: 'right', - fontSize: 11, - color: '#334155', - overflow: 'truncate', - ellipsis: '…', - width: 160, - }, - lineStyle: { - width: 1.5, - color: '#CBD5E1', - curveness: 0.5, - }, - emphasis: { - focus: 'ancestor', - itemStyle: { - borderWidth: 2, - }, - label: { - fontWeight: 'bold', - }, - }, - animationDuration: 350, - animationDurationUpdate: 300, + }; + + // Single root → one series, full area + if (trees.length === 1) { + return { + tooltip, + series: [{ + ...TREE_SERIES_DEFAULTS, left: 40, right: 180, top: 20, bottom: 20, - data: echartsData.value, - }, - ], - }; + data: [trees[0]], + }], + }; + } + + // Multiple roots → one series per tree, each in its own vertical band + const leafCounts = trees.map(countLeaves); + const totalLeaves = leafCounts.reduce((a, b) => a + b, 0); + const GAP_PX = 12; + const totalGapPercent = ((trees.length - 1) * GAP_PX / 800) * 100; + const usablePercent = 100 - totalGapPercent; + + let cursor = 0; + const series = trees.map((tree, index) => { + const fraction = leafCounts[index] / totalLeaves; + const heightPercent = Math.max(10, usablePercent * fraction); + const topPercent = cursor; + cursor += heightPercent + (GAP_PX / 800) * 100; + + return { + ...TREE_SERIES_DEFAULTS, + left: 40, + right: 180, + top: `${topPercent}%`, + height: `${heightPercent}%`, + data: [tree], + }; + }); + + return { tooltip, series }; }); function handleNodeClick(params) { diff --git a/frontend/src/query-tool/components/LotTimeline.vue b/frontend/src/query-tool/components/LotTimeline.vue index b406f2d..61218c7 100644 --- a/frontend/src/query-tool/components/LotTimeline.vue +++ b/frontend/src/query-tool/components/LotTimeline.vue @@ -24,16 +24,18 @@ function safeDate(value) { return parsed ? parsed : null; } -function fallbackTrackId() { - const first = props.historyRows[0]; - return normalizeText(first?.WORKCENTERNAME) || 'UNKNOWN_TRACK'; -} - +// ── Tracks: group by (WORKCENTER_GROUP × LOT ID × Equipment) ── const tracks = computed(() => { const grouped = new Map(); props.historyRows.forEach((row, index) => { - const workcenterName = normalizeText(row?.WORKCENTERNAME) || `WORKCENTER-${index + 1}`; + const groupName = normalizeText(row?.WORKCENTER_GROUP) + || normalizeText(row?.WORKCENTERNAME) + || `WORKCENTER-${index + 1}`; + const lotId = normalizeText(row?.CONTAINERNAME || row?.CONTAINERID) || ''; + const equipment = normalizeText(row?.EQUIPMENTNAME) || ''; + const trackKey = `${groupName}||${lotId}||${equipment}`; + const start = safeDate(row?.TRACKINTIMESTAMP); const end = safeDate(row?.TRACKOUTTIMESTAMP) || (start ? new Date(start.getTime() + (1000 * 60 * 30)) : null); @@ -41,26 +43,30 @@ const tracks = computed(() => { return; } - if (!grouped.has(workcenterName)) { - grouped.set(workcenterName, []); + if (!grouped.has(trackKey)) { + grouped.set(trackKey, { groupName, lotId, equipment, bars: [] }); } - grouped.get(workcenterName).push({ - id: `${workcenterName}-${index}`, + grouped.get(trackKey).bars.push({ + id: `${trackKey}-${index}`, start, end, - type: workcenterName, - label: row?.SPECNAME || workcenterName, - detail: `${normalizeText(row?.CONTAINERNAME || row?.CONTAINERID)} | ${normalizeText(row?.EQUIPMENTNAME)}`, + type: groupName, + label: row?.SPECNAME || groupName, }); }); - return [...grouped.entries()].map(([trackId, bars]) => ({ - id: trackId, - label: trackId, + return [...grouped.entries()].map(([trackKey, { groupName, lotId, equipment, bars }]) => ({ + id: trackKey, + group: groupName, + label: groupName, + sublabels: [ + lotId ? `LOT ID: ${lotId}` : '', + equipment ? `機台編號: ${equipment}` : '', + ].filter(Boolean), layers: [ { - id: `${trackId}-lots`, + id: `${trackKey}-lots`, bars, opacity: 0.85, }, @@ -68,6 +74,22 @@ const tracks = computed(() => { })); }); +// ── Events: resolve trackId to compound key via group matching ── +const groupToFirstTrackId = computed(() => { + const map = new Map(); + tracks.value.forEach((track) => { + if (!map.has(track.group)) { + map.set(track.group, track.id); + } + }); + return map; +}); + +function resolveEventTrackId(row) { + const group = normalizeText(row?.WORKCENTER_GROUP) || normalizeText(row?.WORKCENTERNAME) || ''; + return groupToFirstTrackId.value.get(group) || group; +} + const events = computed(() => { const markers = []; @@ -79,7 +101,7 @@ const events = computed(() => { markers.push({ id: `hold-${index}`, - trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(), + trackId: resolveEventTrackId(row), time, type: 'HOLD', shape: 'diamond', @@ -96,7 +118,7 @@ const events = computed(() => { markers.push({ id: `material-${index}`, - trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(), + trackId: resolveEventTrackId(row), time, type: 'MATERIAL', shape: 'triangle', @@ -114,14 +136,22 @@ const colorMap = computed(() => { MATERIAL: '#0ea5e9', }; + // Color by workcenter group (not compound key) so same group = same color + const seen = new Set(); tracks.value.forEach((track) => { - colors[track.id] = hashColor(track.id); + if (!seen.has(track.group)) { + seen.add(track.group); + colors[track.group] = hashColor(track.group); + } }); return colors; }); const timeRange = computed(() => { + // Derive range ONLY from history bars so it updates when LOT selection + // or workcenter group filter changes. Hold/material events are supplementary + // markers and should not stretch the visible range. const timestamps = []; tracks.value.forEach((track) => { @@ -133,10 +163,6 @@ const timeRange = computed(() => { }); }); - events.value.forEach((eventItem) => { - timestamps.push(eventItem.time?.getTime?.() || 0); - }); - const normalized = timestamps.filter((item) => Number.isFinite(item) && item > 0); if (normalized.length === 0) { return null; @@ -163,15 +189,15 @@ const timeRange = computed(() => { 歷程資料不足,無法產生 Timeline -
+
diff --git a/frontend/src/shared-ui/components/TimelineChart.vue b/frontend/src/shared-ui/components/TimelineChart.vue index 205d04f..275902e 100644 --- a/frontend/src/shared-ui/components/TimelineChart.vue +++ b/frontend/src/shared-ui/components/TimelineChart.vue @@ -1,5 +1,5 @@