feat(resource-status): enrich LOT tooltip with product/material info and draggable header
Add WIP detail API integration to FloatingTooltip for LOT popups, displaying product info (Product, Product Line, Package, Workorder) and material info (Wafer Lot ID, Wafer P/N, Leadframe, Compound) with client-side caching. Make the tooltip header draggable for both LOT and JOB popups. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { apiGet } from '../../core/api.js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -24,6 +26,9 @@ const emit = defineEmits(['close']);
|
|||||||
|
|
||||||
const tooltipRef = ref(null);
|
const tooltipRef = ref(null);
|
||||||
const tooltipStyle = reactive({ left: '0px', top: '0px' });
|
const tooltipStyle = reactive({ left: '0px', top: '0px' });
|
||||||
|
const lotDetailMap = ref({});
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragOffset = { x: 0, y: 0 };
|
||||||
|
|
||||||
const tooltipTitle = computed(() => {
|
const tooltipTitle = computed(() => {
|
||||||
if (props.type === 'job') {
|
if (props.type === 'job') {
|
||||||
@@ -39,6 +44,48 @@ const lotItems = computed(() => {
|
|||||||
return props.payload;
|
return props.payload;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function fetchLotDetails(lots) {
|
||||||
|
const ids = lots.map((lot) => lot.RUNCARDLOTID).filter(Boolean);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = ids.filter((id) => !lotDetailMap.value[id]);
|
||||||
|
if (pending.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
pending.map((id) =>
|
||||||
|
apiGet(`/api/wip/lot/${encodeURIComponent(id)}`, { timeout: 15000 })
|
||||||
|
.then((result) => {
|
||||||
|
const data = result?.success ? result.data : result?.data !== undefined ? result.data : result;
|
||||||
|
return { id, data };
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = { ...lotDetailMap.value };
|
||||||
|
for (const entry of results) {
|
||||||
|
if (entry.status === 'fulfilled' && entry.value?.data) {
|
||||||
|
updated[entry.value.id] = entry.value.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lotDetailMap.value = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLotDetail(lotId) {
|
||||||
|
return lotDetailMap.value[lotId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lotDetailValue(detail, key) {
|
||||||
|
const value = detail?.[key];
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
const jobFields = computed(() => {
|
const jobFields = computed(() => {
|
||||||
if (!props.payload || Array.isArray(props.payload)) {
|
if (!props.payload || Array.isArray(props.payload)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -106,6 +153,38 @@ function handleOutsideClick(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDragStart(event) {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isDragging.value = true;
|
||||||
|
dragOffset.x = event.clientX - parseFloat(tooltipStyle.left);
|
||||||
|
dragOffset.y = event.clientY - parseFloat(tooltipStyle.top);
|
||||||
|
document.addEventListener('mousemove', onDragMove);
|
||||||
|
document.addEventListener('mouseup', onDragEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(event) {
|
||||||
|
if (!isDragging.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const padding = 10;
|
||||||
|
let nextX = event.clientX - dragOffset.x;
|
||||||
|
let nextY = event.clientY - dragOffset.y;
|
||||||
|
|
||||||
|
nextX = Math.max(padding, Math.min(nextX, window.innerWidth - padding));
|
||||||
|
nextY = Math.max(padding, Math.min(nextY, window.innerHeight - padding));
|
||||||
|
|
||||||
|
tooltipStyle.left = `${nextX}px`;
|
||||||
|
tooltipStyle.top = `${nextY}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
isDragging.value = false;
|
||||||
|
document.removeEventListener('mousemove', onDragMove);
|
||||||
|
document.removeEventListener('mouseup', onDragEnd);
|
||||||
|
}
|
||||||
|
|
||||||
function bindOverlayListeners() {
|
function bindOverlayListeners() {
|
||||||
document.addEventListener('click', handleOutsideClick, true);
|
document.addEventListener('click', handleOutsideClick, true);
|
||||||
window.addEventListener('resize', positionTooltip);
|
window.addEventListener('resize', positionTooltip);
|
||||||
@@ -114,6 +193,8 @@ function bindOverlayListeners() {
|
|||||||
function unbindOverlayListeners() {
|
function unbindOverlayListeners() {
|
||||||
document.removeEventListener('click', handleOutsideClick, true);
|
document.removeEventListener('click', handleOutsideClick, true);
|
||||||
window.removeEventListener('resize', positionTooltip);
|
window.removeEventListener('resize', positionTooltip);
|
||||||
|
document.removeEventListener('mousemove', onDragMove);
|
||||||
|
document.removeEventListener('mouseup', onDragEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -127,6 +208,10 @@ watch(
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
positionTooltip();
|
positionTooltip();
|
||||||
bindOverlayListeners();
|
bindOverlayListeners();
|
||||||
|
|
||||||
|
if (props.type === 'lot' && Array.isArray(props.payload) && props.payload.length > 0) {
|
||||||
|
void fetchLotDetails(props.payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -138,7 +223,7 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="visible" ref="tooltipRef" class="floating-tooltip" :style="tooltipStyle" @click.stop>
|
<div v-if="visible" ref="tooltipRef" class="floating-tooltip" :style="tooltipStyle" @click.stop>
|
||||||
<div class="floating-tooltip-header">
|
<div class="floating-tooltip-header" :class="{ dragging: isDragging }" @mousedown="onDragStart">
|
||||||
<h3 class="floating-tooltip-title">{{ tooltipTitle }}</h3>
|
<h3 class="floating-tooltip-title">{{ tooltipTitle }}</h3>
|
||||||
<button type="button" class="floating-tooltip-close" @click="$emit('close')">×</button>
|
<button type="button" class="floating-tooltip-close" @click="$emit('close')">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,11 +242,50 @@ onBeforeUnmount(() => {
|
|||||||
<span class="tooltip-field-label">Track-in</span>
|
<span class="tooltip-field-label">Track-in</span>
|
||||||
<span class="tooltip-field-value">{{ formatDate(lot.LOTTRACKINTIME) }}</span>
|
<span class="tooltip-field-value">{{ formatDate(lot.LOTTRACKINTIME) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tooltip-field">
|
|
||||||
<span class="tooltip-field-label">Employee</span>
|
|
||||||
<span class="tooltip-field-value">{{ lot.LOTTRACKINEMPLOYEE || '--' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="getLotDetail(lot.RUNCARDLOTID)">
|
||||||
|
<div class="lot-section-label">產品資訊</div>
|
||||||
|
<div class="lot-grid">
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Product</span>
|
||||||
|
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'product') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Product Line</span>
|
||||||
|
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'productLine') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Package</span>
|
||||||
|
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'packageLef') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Workorder</span>
|
||||||
|
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'workorder') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lot-section-label">物料資訊</div>
|
||||||
|
<div class="lot-grid">
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Wafer Lot ID</span>
|
||||||
|
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'waferLotId') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Wafer P/N</span>
|
||||||
|
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'waferPn') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Leadframe</span>
|
||||||
|
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'leadframeName') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-field">
|
||||||
|
<span class="tooltip-field-label">Compound</span>
|
||||||
|
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'compoundName') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="lot.RUNCARDLOTID" class="lot-detail-loading-hint">載入詳細資料中...</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="tooltip-empty">無 LOT 明細</div>
|
<div v-else class="tooltip-empty">無 LOT 明細</div>
|
||||||
|
|||||||
@@ -222,6 +222,12 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
border-bottom: 1px solid #dbeafe;
|
border-bottom: 1px solid #dbeafe;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-tooltip-header.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-tooltip-title {
|
.floating-tooltip-title {
|
||||||
@@ -265,6 +271,22 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lot-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lot-detail-loading-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.lot-grid,
|
.lot-grid,
|
||||||
.job-grid {
|
.job-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user