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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user