fix(hold-history): align KPI cards with trend data, improve filters and UX across pages

Use Daily Trend as single source of truth for On Hold and New Hold KPI cards
instead of separate snapshot SQL queries, eliminating value mismatches. Fix
timezone bug in default date range (toISOString UTC offset), add 1st-of-month
fallback to previous month, replace Hold Type radio buttons with select dropdown,
reorder/relabel summary cards with 累計 prefix, add job-query MultiSelect for
equipment filter, and fix heatmap chart X-axis overlap with visualMap legend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-12 08:13:07 +08:00
parent 35d83d424c
commit 8550f6dc3e
13 changed files with 100 additions and 144 deletions

View File

@@ -50,7 +50,10 @@ const errorMessage = ref('');
let activeRequestId = 0;
function toDateString(value) {
return value.toISOString().slice(0, 10);
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function getUrlParam(name) {
@@ -75,8 +78,19 @@ function parseRecordTypeCsv(value) {
function setDefaultDateRange() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
let year = now.getFullYear();
let month = now.getMonth();
if (now.getDate() === 1) {
month -= 1;
if (month < 0) {
month = 11;
year -= 1;
}
}
const start = new Date(year, month, 1);
const end = new Date(year, month + 1, 0);
filterBar.startDate = toDateString(start);
filterBar.endDate = toDateString(end);
}
@@ -359,14 +373,18 @@ const summary = computed(() => {
const netChange = releaseQty - newHoldQty - futureHoldQty;
const avgHoldHours = estimateAvgHoldHours(durationData.value?.items || []);
const counts = trendData.value?.stillOnHoldCount || {};
const stillOnHoldCount = Number(counts[trendTypeKey.value] || 0);
const today = new Date().toISOString().slice(0, 10);
const pastDays = days.filter((d) => d.date <= today);
const lastDay = pastDays.length > 0 ? pastDays[pastDays.length - 1] : {};
const stillOnHoldCount = Number(lastDay.holdQty || 0);
const newHoldSnapshotCount = Number(lastDay.newHoldQty || 0);
return {
releaseQty,
newHoldQty,
futureHoldQty,
stillOnHoldCount,
newHoldSnapshotCount,
netChange,
avgHoldHours,
};

View File

@@ -83,21 +83,17 @@ const holdTypeModel = computed({
</div>
<div class="filter-group hold-type-group">
<span class="filter-label">Hold Type</span>
<div class="radio-group">
<label class="radio-option" :class="{ active: holdTypeModel === 'quality' }">
<input v-model="holdTypeModel" type="radio" value="quality" :disabled="disabled" />
<span>品質異常</span>
</label>
<label class="radio-option" :class="{ active: holdTypeModel === 'non-quality' }">
<input v-model="holdTypeModel" type="radio" value="non-quality" :disabled="disabled" />
<span>非品質異常</span>
</label>
<label class="radio-option" :class="{ active: holdTypeModel === 'all' }">
<input v-model="holdTypeModel" type="radio" value="all" :disabled="disabled" />
<span>全部</span>
</label>
</div>
<label class="filter-label" for="hold-history-hold-type">Hold Type</label>
<select
id="hold-history-hold-type"
v-model="holdTypeModel"
class="hold-type-select"
:disabled="disabled"
>
<option value="quality">品質異常</option>
<option value="non-quality">非品質異常</option>
<option value="all">全部</option>
</select>
</div>
</section>
</template>

View File

@@ -7,6 +7,7 @@ const props = defineProps({
newHoldQty: 0,
futureHoldQty: 0,
stillOnHoldCount: 0,
newHoldSnapshotCount: 0,
netChange: 0,
avgHoldHours: 0,
}),
@@ -24,28 +25,33 @@ function formatHours(value) {
<template>
<section class="summary-row hold-history-summary-row">
<article class="summary-card stat-positive">
<div class="summary-label">Release 數量</div>
<div class="summary-value">{{ formatNumber(summary.releaseQty) }}</div>
</article>
<article class="summary-card stat-negative-red">
<div class="summary-label">New Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.newHoldQty) }}</div>
</article>
<article class="summary-card stat-negative-orange">
<div class="summary-label">Future Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.futureHoldQty) }}</div>
</article>
<article class="summary-card stat-negative-red">
<div class="summary-label">On Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.stillOnHoldCount) }}</div>
</article>
<article class="summary-card stat-negative-orange">
<div class="summary-label">最末日新增 Hold</div>
<div class="summary-value">{{ formatNumber(summary.newHoldSnapshotCount) }}</div>
</article>
<article class="summary-card stat-negative-red">
<div class="summary-label">累計新增 Hold</div>
<div class="summary-value">{{ formatNumber(summary.newHoldQty) }}</div>
</article>
<article class="summary-card stat-positive">
<div class="summary-label">累計 Release</div>
<div class="summary-value">{{ formatNumber(summary.releaseQty) }}</div>
</article>
<article class="summary-card stat-negative-orange">
<div class="summary-label">累計 Future Hold</div>
<div class="summary-value">{{ formatNumber(summary.futureHoldQty) }}</div>
</article>
<article class="summary-card">
<div class="summary-label">淨變動 (Release - New - Future)</div>
<div class="summary-label">累計淨變動 (Release - New - Future)</div>
<div class="summary-value" :class="{ positive: summary.netChange >= 0, negative: summary.netChange < 0 }">
{{ formatNumber(summary.netChange) }}
</div>
@@ -53,7 +59,7 @@ function formatHours(value) {
<article class="summary-card">
<div class="summary-label">平均 Hold 時長</div>
<div class="summary-value small">{{ formatHours(summary.avgHoldHours) }}</div>
<div class="summary-value">{{ formatHours(summary.avgHoldHours) }}</div>
</article>
</section>
</template>

View File

@@ -85,14 +85,27 @@
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
}
.radio-group,
.hold-type-select {
min-width: 140px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
background: #ffffff;
}
.hold-type-select:focus {
outline: none;
border-color: #0ea5e9;
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
}
.checkbox-group {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
}
.radio-option,
.checkbox-option {
display: inline-flex;
align-items: center;
@@ -105,7 +118,6 @@
font-size: 13px;
}
.radio-option.active,
.checkbox-option.active {
border-color: #0284c7;
background: #e0f2fe;
@@ -120,7 +132,7 @@
}
.hold-history-summary-row {
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.summary-card {
@@ -290,7 +302,7 @@
@media (max-width: 1440px) {
.hold-history-summary-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted } from 'vue';
import { computed, onMounted } from 'vue';
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
import SectionCard from '../shared-ui/components/SectionCard.vue';
import StatusBadge from '../shared-ui/components/StatusBadge.vue';
@@ -20,18 +21,23 @@ const {
selectedJobId,
txnRows,
txnColumns,
filteredResources,
selectedResourceCount,
resetDateRangeToLast90Days,
hydrateFiltersFromUrl,
loadResources,
toggleResource,
queryJobs,
loadTxn,
exportCsv,
getStatusTone,
} = useJobQueryData();
const resourceOptions = computed(() =>
resources.value.map((item) => ({
value: item.RESOURCEID,
label: item.RESOURCENAME || item.RESOURCEID,
})),
);
function formatCellValue(value) {
if (value === null || value === undefined || value === '') {
return '-';
@@ -77,8 +83,15 @@ onMounted(async () => {
<input v-model="filters.endDate" type="date" />
</label>
<label class="job-query-filter">
<span>設備搜尋</span>
<input v-model="filters.searchText" type="text" placeholder="輸入設備/站點/群組..." />
<span>設備複選</span>
<MultiSelect
:model-value="filters.resourceIds"
:options="resourceOptions"
:disabled="loadingResources"
placeholder="全部設備"
searchable
@update:model-value="filters.resourceIds = $event"
/>
</label>
<template #actions>
@@ -90,27 +103,6 @@ onMounted(async () => {
</button>
</template>
</FilterToolbar>
<div class="job-query-resource-grid">
<div v-if="loadingResources" class="job-query-empty">載入設備中...</div>
<label
v-for="resource in filteredResources"
:key="resource.RESOURCEID"
class="job-query-resource"
:class="{ selected: filters.resourceIds.includes(resource.RESOURCEID) }"
>
<input
type="checkbox"
:checked="filters.resourceIds.includes(resource.RESOURCEID)"
@change="toggleResource(resource.RESOURCEID)"
/>
<div class="job-query-resource-meta">
<strong>{{ resource.RESOURCENAME || resource.RESOURCEID }}</strong>
<span>{{ resource.WORKCENTERNAME || '-' }} / {{ resource.RESOURCEFAMILYNAME || '-' }}</span>
</div>
</label>
<div v-if="!loadingResources && filteredResources.length === 0" class="job-query-empty">無可用設備</div>
</div>
</SectionCard>
<p v-if="errorMessage" class="job-query-error">{{ errorMessage }}</p>

View File

@@ -48,7 +48,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
),
'/job-query': createNativeLoader(
() => import('../job-query/App.vue'),
[() => import('../job-query/style.css')],
[() => import('../resource-shared/styles.css'), () => import('../job-query/style.css')],
),
'/excel-query': createNativeLoader(
() => import('../excel-query/App.vue'),

View File

@@ -64,7 +64,7 @@ const chartOption = computed(() => {
left: 110,
right: 20,
top: 20,
bottom: 64,
bottom: 100,
},
xAxis: {
type: 'category',
@@ -88,7 +88,7 @@ const chartOption = computed(() => {
max: 100,
orient: 'horizontal',
left: 'center',
bottom: 10,
bottom: 4,
inRange: {
color: ['#ef4444', '#f59e0b', '#22c55e'],
},