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
-