feat: polish reject history UI and enhance WIP filter interactions

This commit is contained in:
egg
2026-02-22 11:54:51 +08:00
parent 9687deb9ad
commit 7bf9e33cd5
35 changed files with 3054 additions and 1085 deletions

View File

@@ -5,6 +5,16 @@ function toTrimmedString(value) {
return String(value).trim();
}
function normalizeFilterValue(value) {
if (Array.isArray(value)) {
return value
.map((item) => toTrimmedString(item))
.filter((item) => item.length > 0)
.join(',');
}
return toTrimmedString(value);
}
export function normalizeStatusFilter(statusFilter) {
if (!statusFilter) {
return {};
@@ -20,15 +30,19 @@ export function normalizeStatusFilter(statusFilter) {
export function buildWipOverviewQueryParams(filters = {}, statusFilter = null) {
const params = {};
const workorder = toTrimmedString(filters.workorder);
const lotid = toTrimmedString(filters.lotid);
const pkg = toTrimmedString(filters.package);
const type = toTrimmedString(filters.type);
const workorder = normalizeFilterValue(filters.workorder);
const lotid = normalizeFilterValue(filters.lotid);
const pkg = normalizeFilterValue(filters.package);
const type = normalizeFilterValue(filters.type);
const firstname = normalizeFilterValue(filters.firstname);
const waferdesc = normalizeFilterValue(filters.waferdesc);
if (workorder) params.workorder = workorder;
if (lotid) params.lotid = lotid;
if (pkg) params.package = pkg;
if (type) params.type = type;
if (firstname) params.firstname = firstname;
if (waferdesc) params.waferdesc = waferdesc;
return { ...params, ...normalizeStatusFilter(statusFilter) };
}

View File

@@ -1,17 +1,14 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
import { apiGet } from '../core/api.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
import DetailTable from './components/DetailTable.vue';
import FilterPanel from './components/FilterPanel.vue';
import ParetoSection from './components/ParetoSection.vue';
import SummaryCards from './components/SummaryCards.vue';
import TrendChart from './components/TrendChart.vue';
const API_TIMEOUT = 60000;
const DEFAULT_PER_PAGE = 50;
@@ -24,11 +21,14 @@ const filters = reactive({
reason: '',
includeExcludedScrap: false,
excludeMaterialScrap: true,
excludePbDiode: true,
paretoTop80: true,
});
const page = ref(1);
const detailReason = ref('');
const selectedTrendDates = ref([]);
const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true });
const options = reactive({
workcenterGroups: [],
@@ -48,7 +48,7 @@ const summary = ref({
});
const trend = ref({ items: [], granularity: 'day' });
const pareto = ref({ items: [], metric_mode: 'reject_total', pareto_scope: 'top80' });
const analyticsRawItems = ref([]);
const detail = ref({
items: [],
pagination: {
@@ -64,7 +64,6 @@ const loading = reactive({
querying: false,
options: false,
list: false,
pareto: false,
});
const errorMessage = ref('');
@@ -150,9 +149,14 @@ function restoreFromUrl() {
if (detailReasonFromUrl) {
detailReason.value = detailReasonFromUrl;
}
const trendDates = readArrayParam(params, 'trend_dates');
if (trendDates.length > 0) {
selectedTrendDates.value = trendDates;
}
filters.includeExcludedScrap = readBooleanParam(params, 'include_excluded_scrap', false);
filters.excludeMaterialScrap = readBooleanParam(params, 'exclude_material_scrap', true);
filters.excludePbDiode = readBooleanParam(params, 'exclude_pb_diode', true);
filters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false);
const parsedPage = Number(params.get('page') || '1');
@@ -173,11 +177,13 @@ function updateUrlState() {
if (detailReason.value) {
params.set('detail_reason', detailReason.value);
}
selectedTrendDates.value.forEach((d) => params.append('trend_dates', d));
if (filters.includeExcludedScrap) {
params.set('include_excluded_scrap', 'true');
}
params.set('exclude_material_scrap', String(filters.excludeMaterialScrap));
params.set('exclude_pb_diode', String(filters.excludePbDiode));
if (!filters.paretoTop80) {
params.set('pareto_scope_all', 'true');
@@ -190,14 +196,6 @@ function updateUrlState() {
replaceRuntimeHistory(`/reject-history?${params.toString()}`);
}
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatPct(value) {
return `${Number(value || 0).toFixed(2)}%`;
}
function unwrapApiResult(result, fallbackMessage) {
if (result?.success === true) {
return result;
@@ -216,6 +214,7 @@ function buildCommonParams({ reason = filters.reason } = {}) {
packages: filters.packages,
include_excluded_scrap: filters.includeExcludedScrap,
exclude_material_scrap: filters.excludeMaterialScrap,
exclude_pb_diode: filters.excludePbDiode,
};
if (reason) {
@@ -225,21 +224,19 @@ function buildCommonParams({ reason = filters.reason } = {}) {
return params;
}
function buildParetoParams() {
return {
...buildCommonParams({ reason: filters.reason }),
metric_mode: 'reject_total',
pareto_scope: filters.paretoTop80 ? 'top80' : 'all',
};
}
function buildListParams() {
const effectiveReason = detailReason.value || filters.reason;
return {
const params = {
...buildCommonParams({ reason: effectiveReason }),
page: page.value,
per_page: DEFAULT_PER_PAGE,
};
if (selectedTrendDates.value.length > 0) {
const sorted = [...selectedTrendDates.value].sort();
params.start_date = sorted[0];
params.end_date = sorted[sorted.length - 1];
}
return params;
}
async function fetchOptions() {
@@ -249,6 +246,7 @@ async function fetchOptions() {
end_date: filters.endDate,
include_excluded_scrap: filters.includeExcludedScrap,
exclude_material_scrap: filters.excludeMaterialScrap,
exclude_pb_diode: filters.excludePbDiode,
},
timeout: API_TIMEOUT,
});
@@ -256,33 +254,15 @@ async function fetchOptions() {
return payload.data || {};
}
async function fetchSummary() {
const response = await apiGet('/api/reject-history/summary', {
params: buildCommonParams(),
timeout: API_TIMEOUT,
});
const payload = unwrapApiResult(response, '載入摘要資料失敗');
return payload;
}
async function fetchTrend() {
const response = await apiGet('/api/reject-history/trend', {
async function fetchAnalytics() {
const response = await apiGet('/api/reject-history/analytics', {
params: {
...buildCommonParams(),
granularity: 'day',
metric_mode: 'reject_total',
},
timeout: API_TIMEOUT,
});
const payload = unwrapApiResult(response, '載入趨勢資料失敗');
return payload;
}
async function fetchPareto() {
const response = await apiGet('/api/reject-history/reason-pareto', {
params: buildParetoParams(),
timeout: API_TIMEOUT,
});
const payload = unwrapApiResult(response, '載入柏拉圖資料失敗');
const payload = unwrapApiResult(response, '載入分析資料失敗');
return payload;
}
@@ -319,11 +299,10 @@ async function loadAllData({ loadOptions = true } = {}) {
loading.querying = true;
loading.list = true;
loading.pareto = true;
errorMessage.value = '';
try {
const tasks = [fetchSummary(), fetchTrend(), fetchPareto(), fetchList()];
const tasks = [fetchAnalytics(), fetchList()];
if (loadOptions) {
loading.options = true;
tasks.push(fetchOptions());
@@ -334,17 +313,16 @@ async function loadAllData({ loadOptions = true } = {}) {
return;
}
const [summaryResp, trendResp, paretoResp, listResp, optionsResp] = responses;
const [analyticsResp, listResp, optionsResp] = responses;
summary.value = summaryResp.data || summary.value;
trend.value = trendResp.data || trend.value;
pareto.value = paretoResp.data || pareto.value;
const analyticsData = analyticsResp.data || {};
summary.value = analyticsData.summary || summary.value;
trend.value = analyticsData.trend || trend.value;
analyticsRawItems.value = Array.isArray(analyticsData.raw_items) ? analyticsData.raw_items : [];
detail.value = listResp.data || detail.value;
const meta = {
...(summaryResp.meta || {}),
...(trendResp.meta || {}),
...(paretoResp.meta || {}),
...(analyticsResp.meta || {}),
...(listResp.meta || {}),
};
mergePolicyMeta(meta);
@@ -377,7 +355,6 @@ async function loadAllData({ loadOptions = true } = {}) {
loading.querying = false;
loading.options = false;
loading.list = false;
loading.pareto = false;
}
}
@@ -407,35 +384,10 @@ async function loadListOnly() {
}
}
async function loadParetoOnly() {
const requestId = nextRequestId();
loading.pareto = true;
errorMessage.value = '';
try {
const paretoResp = await fetchPareto();
if (isStaleRequest(requestId)) {
return;
}
pareto.value = paretoResp.data || pareto.value;
mergePolicyMeta(paretoResp.meta || {});
updateUrlState();
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入柏拉圖資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
loading.pareto = false;
}
}
function applyFilters() {
page.value = 1;
detailReason.value = '';
selectedTrendDates.value = [];
void loadAllData({ loadOptions: true });
}
@@ -445,8 +397,10 @@ function clearFilters() {
filters.packages = [];
filters.reason = '';
detailReason.value = '';
selectedTrendDates.value = [];
filters.includeExcludedScrap = false;
filters.excludeMaterialScrap = true;
filters.excludePbDiode = true;
filters.paretoTop80 = true;
page.value = 1;
void loadAllData({ loadOptions: true });
@@ -460,6 +414,25 @@ function goToPage(nextPage) {
void loadListOnly();
}
function onTrendDateClick(dateStr) {
if (!dateStr) {
return;
}
const idx = selectedTrendDates.value.indexOf(dateStr);
if (idx >= 0) {
selectedTrendDates.value = selectedTrendDates.value.filter((d) => d !== dateStr);
} else {
selectedTrendDates.value = [...selectedTrendDates.value, dateStr];
}
page.value = 1;
void loadListOnly();
}
function onTrendLegendChange(selected) {
trendLegendSelected.value = selected;
updateUrlState();
}
function onParetoClick(reason) {
if (!reason) {
return;
@@ -471,7 +444,7 @@ function onParetoClick(reason) {
function handleParetoScopeToggle(checked) {
filters.paretoTop80 = Boolean(checked);
void loadParetoOnly();
updateUrlState();
}
function removeFilterChip(chip) {
@@ -491,6 +464,11 @@ function removeFilterChip(chip) {
page.value = 1;
void loadListOnly();
return;
} else if (chip.type === 'trend-dates') {
selectedTrendDates.value = [];
page.value = 1;
void loadListOnly();
return;
} else {
return;
}
@@ -505,6 +483,7 @@ function exportCsv() {
params.set('end_date', filters.endDate);
params.set('include_excluded_scrap', String(filters.includeExcludedScrap));
params.set('exclude_material_scrap', String(filters.excludeMaterialScrap));
params.set('exclude_pb_diode', String(filters.excludePbDiode));
filters.workcenterGroups.forEach((item) => params.append('workcenter_groups', item));
filters.packages.forEach((item) => params.append('packages', item));
@@ -520,6 +499,85 @@ const totalScrapQty = computed(() => {
return Number(summary.value.REJECT_TOTAL_QTY || 0) + Number(summary.value.DEFECT_QTY || 0);
});
const paretoMetricMode = computed(() => {
const s = trendLegendSelected.value;
const rejectOn = s['扣帳報廢量'] !== false;
const defectOn = s['不扣帳報廢量'] !== false;
if (rejectOn && defectOn) return 'all';
if (rejectOn) return 'reject';
if (defectOn) return 'defect';
return 'none';
});
const paretoMetricLabel = computed(() => {
switch (paretoMetricMode.value) {
case 'reject': return '扣帳報廢量';
case 'defect': return '不扣帳報廢量';
case 'none': return '報廢量';
default: return '全部報廢量';
}
});
const allParetoItems = computed(() => {
const raw = analyticsRawItems.value;
if (!raw || raw.length === 0) return [];
const mode = paretoMetricMode.value;
if (mode === 'none') return [];
const dateSet = selectedTrendDates.value.length > 0 ? new Set(selectedTrendDates.value) : null;
const filtered = dateSet ? raw.filter((r) => dateSet.has(r.bucket_date)) : raw;
if (filtered.length === 0) return [];
const map = new Map();
for (const item of filtered) {
const key = item.reason;
if (!map.has(key)) {
map.set(key, { reason: key, MOVEIN_QTY: 0, REJECT_TOTAL_QTY: 0, DEFECT_QTY: 0, AFFECTED_LOT_COUNT: 0 });
}
const acc = map.get(key);
acc.MOVEIN_QTY += Number(item.MOVEIN_QTY || 0);
acc.REJECT_TOTAL_QTY += Number(item.REJECT_TOTAL_QTY || 0);
acc.DEFECT_QTY += Number(item.DEFECT_QTY || 0);
acc.AFFECTED_LOT_COUNT += Number(item.AFFECTED_LOT_COUNT || 0);
}
const withMetric = Array.from(map.values()).map((row) => {
let mv;
if (mode === 'all') mv = row.REJECT_TOTAL_QTY + row.DEFECT_QTY;
else if (mode === 'reject') mv = row.REJECT_TOTAL_QTY;
else mv = row.DEFECT_QTY;
return { ...row, metric_value: mv };
});
const sorted = withMetric.filter((r) => r.metric_value > 0).sort((a, b) => b.metric_value - a.metric_value);
const total = sorted.reduce((sum, r) => sum + r.metric_value, 0);
let cum = 0;
return sorted.map((row) => {
const pct = total ? Number(((row.metric_value / total) * 100).toFixed(4)) : 0;
cum += pct;
return {
reason: row.reason,
metric_value: row.metric_value,
MOVEIN_QTY: row.MOVEIN_QTY,
REJECT_TOTAL_QTY: row.REJECT_TOTAL_QTY,
DEFECT_QTY: row.DEFECT_QTY,
count: row.AFFECTED_LOT_COUNT,
pct,
cumPct: Number(cum.toFixed(4)),
};
});
});
const filteredParetoItems = computed(() => {
const items = allParetoItems.value || [];
if (!filters.paretoTop80 || items.length === 0) {
return items;
}
const top = items.filter((item) => Number(item.cumPct || 0) <= 80);
return top.length > 0 ? top : [items[0]];
});
const activeFilterChips = computed(() => {
const chips = [
{
@@ -543,6 +601,13 @@ const activeFilterChips = computed(() => {
type: 'policy',
value: '',
},
{
key: 'pb-diode-policy',
label: filters.excludePbDiode ? 'PB_Diode: 已排除' : 'PB_Diode: 已納入',
removable: false,
type: 'policy',
value: '',
},
];
if (filters.reason) {
@@ -554,6 +619,19 @@ const activeFilterChips = computed(() => {
value: filters.reason,
});
}
if (selectedTrendDates.value.length > 0) {
const dates = selectedTrendDates.value;
const label = dates.length === 1
? `趨勢日期: ${dates[0]}`
: `趨勢日期: ${dates.length}`;
chips.push({
key: 'trend-dates',
label,
removable: true,
type: 'trend-dates',
value: '',
});
}
if (detailReason.value) {
chips.push({
key: `detail-reason:${detailReason.value}`,
@@ -598,135 +676,6 @@ const kpiCards = computed(() => {
];
});
const quantityChartOption = computed(() => {
const items = Array.isArray(trend.value?.items) ? trend.value.items : [];
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: {
data: ['扣帳報廢量', '不扣帳報廢量'],
bottom: 0,
},
grid: { left: 48, right: 24, top: 22, bottom: 70 },
xAxis: {
type: 'category',
data: items.map((item) => item.bucket_date || ''),
},
yAxis: {
type: 'value',
axisLabel: {
formatter(value) {
return Number(value || 0).toLocaleString('zh-TW');
},
},
},
series: [
{
name: '扣帳報廢量',
type: 'bar',
data: items.map((item) => Number(item.REJECT_TOTAL_QTY || 0)),
itemStyle: { color: '#dc2626' },
barMaxWidth: 28,
},
{
name: '不扣帳報廢量',
type: 'bar',
data: items.map((item) => Number(item.DEFECT_QTY || 0)),
itemStyle: { color: '#0284c7' },
barMaxWidth: 28,
},
],
};
});
const paretoChartOption = computed(() => {
const items = Array.isArray(pareto.value?.items) ? pareto.value.items : [];
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter(params) {
const idx = Number(params?.[0]?.dataIndex || 0);
const item = items[idx] || {};
return [
`<b>${item.reason || '(未填寫)'}</b>`,
`報廢量: ${formatNumber(item.metric_value || 0)}`,
`占比: ${Number(item.pct || 0).toFixed(2)}%`,
`累計: ${Number(item.cumPct || 0).toFixed(2)}%`,
].join('<br/>');
},
},
legend: {
data: ['報廢量', '累積%'],
bottom: 0,
},
grid: {
left: 52,
right: 52,
top: 20,
bottom: 96,
},
xAxis: {
type: 'category',
data: items.map((item) => item.reason || '(未填寫)'),
axisLabel: {
interval: 0,
rotate: items.length > 6 ? 35 : 0,
fontSize: 11,
overflow: 'truncate',
width: 100,
},
},
yAxis: [
{
type: 'value',
name: '量',
},
{
type: 'value',
name: '%',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%' },
},
],
series: [
{
name: '報廢量',
type: 'bar',
data: items.map((item) => Number(item.metric_value || 0)),
barMaxWidth: 34,
itemStyle: {
color(params) {
const reason = items[params.dataIndex]?.reason || '';
return reason === detailReason.value ? '#b91c1c' : '#2563eb';
},
borderRadius: [4, 4, 0, 0],
},
},
{
name: '累積%',
type: 'line',
yAxisIndex: 1,
data: items.map((item) => Number(item.cumPct || 0)),
lineStyle: { color: '#f59e0b', width: 2 },
itemStyle: { color: '#f59e0b' },
symbolSize: 6,
},
],
};
});
function onParetoChartClick(params) {
if (params?.seriesType !== 'bar') {
return;
}
const selected = pareto.value?.items?.[params.dataIndex]?.reason;
onParetoClick(selected);
}
const pagination = computed(() => detail.value?.pagination || {
page: 1,
perPage: DEFAULT_PER_PAGE,
@@ -734,9 +683,6 @@ const pagination = computed(() => detail.value?.pagination || {
totalPages: 1,
});
const hasTrendData = computed(() => Array.isArray(trend.value?.items) && trend.value.items.length > 0);
const hasParetoData = computed(() => Array.isArray(pareto.value?.items) && pareto.value.items.length > 0);
onMounted(() => {
setDefaultDateRange();
restoreFromUrl();
@@ -752,217 +698,54 @@ onMounted(() => {
</div>
<div class="header-right">
<div class="last-update" v-if="lastQueryAt">更新時間{{ lastQueryAt }}</div>
<button type="button" class="btn btn-light" :disabled="loading.querying" @click="applyFilters">重新整理</button>
</div>
</header>
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
<section class="card">
<div class="card-header">
<div class="card-title">查詢條件</div>
</div>
<div class="card-body filter-panel">
<div class="filter-group">
<label class="filter-label" for="start-date">開始日期</label>
<input id="start-date" v-model="filters.startDate" type="date" class="filter-input" />
</div>
<div class="filter-group">
<label class="filter-label" for="end-date">結束日期</label>
<input id="end-date" v-model="filters.endDate" type="date" class="filter-input" />
</div>
<FilterPanel
:filters="filters"
:options="options"
:loading="loading"
:active-filter-chips="activeFilterChips"
@apply="applyFilters"
@clear="clearFilters"
@export-csv="exportCsv"
@remove-chip="removeFilterChip"
@pareto-scope-toggle="handleParetoScopeToggle"
/>
<div class="filter-group">
<label class="filter-label">Package</label>
<MultiSelect
:model-value="filters.packages"
:options="options.packages"
placeholder="全部 Package"
searchable
@update:model-value="filters.packages = $event"
/>
</div>
<SummaryCards :cards="kpiCards" />
<div class="filter-group filter-group-wide">
<label class="filter-label">WORKCENTER GROUP</label>
<MultiSelect
:model-value="filters.workcenterGroups"
:options="options.workcenterGroups"
placeholder="全部工作中心群組"
searchable
@update:model-value="filters.workcenterGroups = $event"
/>
</div>
<TrendChart
:items="trend.items"
:selected-dates="selectedTrendDates"
:loading="loading.querying"
@date-click="onTrendDateClick"
@legend-change="onTrendLegendChange"
/>
<div class="filter-group filter-group-wide">
<label class="filter-label" for="reason">報廢原因</label>
<select id="reason" v-model="filters.reason" class="filter-input">
<option value="">全部原因</option>
<option v-for="reason in options.reasons" :key="reason" :value="reason">
{{ reason }}
</option>
</select>
</div>
<ParetoSection
:items="filteredParetoItems"
:detail-reason="detailReason"
:selected-dates="selectedTrendDates"
:metric-label="paretoMetricLabel"
:loading="loading.querying"
@reason-click="onParetoClick"
/>
<div class="filter-group filter-group-wide inline-toggle-group">
<div class="checkbox-row">
<label class="checkbox-pill">
<input v-model="filters.includeExcludedScrap" type="checkbox" />
納入不計良率報廢
</label>
<label class="checkbox-pill">
<input v-model="filters.excludeMaterialScrap" type="checkbox" />
排除原物料報廢
</label>
<label class="checkbox-pill">
<input
:checked="filters.paretoTop80"
type="checkbox"
@change="handleParetoScopeToggle($event.target.checked)"
/>
Pareto 僅顯示累計前 80%
</label>
</div>
</div>
<DetailTable
:items="detail.items"
:pagination="pagination"
:loading="loading.list"
:detail-reason="detailReason"
@go-to-page="goToPage"
@clear-reason="onParetoClick(detailReason)"
/>
</div>
<div class="filter-actions">
<button class="btn btn-primary" :disabled="loading.querying" @click="applyFilters">查詢</button>
<button class="btn btn-secondary" :disabled="loading.querying" @click="clearFilters">清除條件</button>
<button class="btn btn-light btn-export" :disabled="loading.querying" @click="exportCsv">匯出 CSV</button>
</div>
</div>
<div class="card-body active-filter-chip-row" v-if="activeFilterChips.length > 0">
<div class="filter-label">套用中篩選</div>
<div class="chip-list">
<div v-for="chip in activeFilterChips" :key="chip.key" class="filter-chip">
<span>{{ chip.label }}</span>
<button
v-if="chip.removable"
type="button"
class="chip-remove"
@click="removeFilterChip(chip)"
>
×
</button>
</div>
</div>
</div>
</section>
<section class="summary-row reject-summary-row">
<article
v-for="card in kpiCards"
:key="card.key"
class="summary-card"
:class="`lane-${card.lane}`"
>
<div class="summary-label">{{ card.label }}</div>
<div class="summary-value small">{{ card.isPct ? formatPct(card.value) : formatNumber(card.value) }}</div>
</article>
</section>
<section class="chart-grid">
<article class="card">
<div class="card-header"><div class="card-title">報廢量趨勢</div></div>
<div class="card-body chart-wrap">
<VChart :option="quantityChartOption" autoresize />
<div v-if="!hasTrendData && !loading.querying" class="placeholder chart-empty">No data</div>
</div>
</article>
</section>
<section class="card">
<div class="card-header pareto-header">
<div class="card-title">報廢量 vs 報廢原因Pareto</div>
</div>
<div class="card-body pareto-layout">
<div class="pareto-chart-wrap">
<VChart :option="paretoChartOption" autoresize @click="onParetoChartClick" />
<div v-if="!hasParetoData && !loading.pareto" class="placeholder chart-empty">No data</div>
</div>
<div class="pareto-table-wrap">
<table class="detail-table pareto-table">
<thead>
<tr>
<th>原因</th>
<th>報廢量</th>
<th>占比</th>
<th>累積</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in pareto.items"
:key="item.reason"
:class="{ active: detailReason === item.reason }"
>
<td>
<button class="reason-link" type="button" @click="onParetoClick(item.reason)">
{{ item.reason }}
</button>
</td>
<td>{{ formatNumber(item.metric_value) }}</td>
<td>{{ formatPct(item.pct) }}</td>
<td>{{ formatPct(item.cumPct) }}</td>
</tr>
<tr v-if="!pareto.items || pareto.items.length === 0">
<td colspan="4" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="card">
<div class="card-header">
<div class="card-title">明細列表</div>
</div>
<div class="card-body detail-table-wrap">
<table class="detail-table">
<thead>
<tr>
<th>日期</th>
<th>WORKCENTER_GROUP</th>
<th>WORKCENTER</th>
<th>Package</th>
<th>原因</th>
<th>REJECT_TOTAL_QTY</th>
<th>DEFECT_QTY</th>
<th>REJECT_QTY</th>
<th>STANDBY_QTY</th>
<th>QTYTOPROCESS_QTY</th>
<th>INPROCESS_QTY</th>
<th>PROCESSED_QTY</th>
</tr>
</thead>
<tbody>
<tr v-for="row in detail.items" :key="`${row.TXN_DAY}-${row.WORKCENTERNAME}-${row.LOSSREASONNAME}`">
<td>{{ row.TXN_DAY }}</td>
<td>{{ row.WORKCENTER_GROUP }}</td>
<td>{{ row.WORKCENTERNAME }}</td>
<td>{{ row.PRODUCTLINENAME }}</td>
<td>{{ row.LOSSREASONNAME }}</td>
<td>{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
<td>{{ formatNumber(row.DEFECT_QTY) }}</td>
<td>{{ formatNumber(row.REJECT_QTY) }}</td>
<td>{{ formatNumber(row.STANDBY_QTY) }}</td>
<td>{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.PROCESSED_QTY) }}</td>
</tr>
<tr v-if="!detail.items || detail.items.length === 0">
<td colspan="12" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="pagination.page <= 1 || loading.list" @click="goToPage(pagination.page - 1)">Prev</button>
<span class="page-info">
Page {{ pagination.page }} / {{ pagination.totalPages }} · Total {{ formatNumber(pagination.total) }}
</span>
<button :disabled="pagination.page >= pagination.totalPages || loading.list" @click="goToPage(pagination.page + 1)">Next</button>
</div>
</section>
<div v-if="loading.initial" class="loading-overlay">
<span class="loading-spinner"></span>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
defineProps({
items: { type: Array, default: () => [] },
pagination: {
type: Object,
default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }),
},
loading: { type: Boolean, default: false },
detailReason: { type: String, default: '' },
});
defineEmits(['go-to-page', 'clear-reason']);
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
</script>
<template>
<section class="card">
<div class="card-header">
<div class="card-title">
明細列表
<span v-if="detailReason" class="detail-reason-badge">
原因: {{ detailReason }}
<button type="button" class="badge-clear" @click="$emit('clear-reason')">×</button>
</span>
</div>
</div>
<div class="card-body detail-table-wrap">
<table class="detail-table">
<thead>
<tr>
<th>日期</th>
<th>LOT</th>
<th>WORKCENTER_GROUP</th>
<th>WORKCENTER</th>
<th>Package</th>
<th>PJ_TYPE</th>
<th>PJ_FUNCTION</th>
<th>PRODUCT</th>
<th>原因</th>
<th>扣帳報廢量</th>
<th>不扣帳報廢量</th>
<th>REJECT_QTY</th>
<th>STANDBY_QTY</th>
<th>QTYTOPROCESS</th>
<th>INPROCESS</th>
<th>PROCESSED</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in items" :key="`${row.TXN_DAY}-${row.WORKCENTERNAME}-${row.CONTAINERNAME}-${row.LOSSREASONNAME}-${idx}`">
<td>{{ row.TXN_DAY }}</td>
<td>{{ row.CONTAINERNAME || '' }}</td>
<td>{{ row.WORKCENTER_GROUP }}</td>
<td>{{ row.WORKCENTERNAME }}</td>
<td>{{ row.PRODUCTLINENAME }}</td>
<td>{{ row.PJ_TYPE }}</td>
<td>{{ row.PJ_FUNCTION || '' }}</td>
<td>{{ row.PRODUCTNAME || '' }}</td>
<td>{{ row.LOSSREASONNAME }}</td>
<td>{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
<td>{{ formatNumber(row.DEFECT_QTY) }}</td>
<td>{{ formatNumber(row.REJECT_QTY) }}</td>
<td>{{ formatNumber(row.STANDBY_QTY) }}</td>
<td>{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.PROCESSED_QTY) }}</td>
</tr>
<tr v-if="!items || items.length === 0">
<td colspan="16" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="pagination.page <= 1 || loading" @click="$emit('go-to-page', pagination.page - 1)">上一頁</button>
<span class="page-info">
{{ pagination.page }} / {{ pagination.totalPages }} · {{ formatNumber(pagination.total) }}
</span>
<button :disabled="pagination.page >= pagination.totalPages || loading" @click="$emit('go-to-page', pagination.page + 1)">下一頁</button>
</div>
</section>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
defineProps({
filters: { type: Object, required: true },
options: { type: Object, required: true },
loading: { type: Object, required: true },
activeFilterChips: { type: Array, default: () => [] },
});
defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle']);
</script>
<template>
<section class="card">
<div class="card-header">
<div class="card-title">查詢條件</div>
</div>
<div class="card-body filter-panel">
<div class="filter-group">
<label class="filter-label" for="start-date">開始日期</label>
<input id="start-date" v-model="filters.startDate" type="date" class="filter-input" />
</div>
<div class="filter-group">
<label class="filter-label" for="end-date">結束日期</label>
<input id="end-date" v-model="filters.endDate" type="date" class="filter-input" />
</div>
<div class="filter-group">
<label class="filter-label">Package</label>
<MultiSelect
:model-value="filters.packages"
:options="options.packages"
placeholder="全部 Package"
searchable
@update:model-value="filters.packages = $event"
/>
</div>
<div class="filter-group">
<label class="filter-label" for="reason">報廢原因</label>
<select id="reason" v-model="filters.reason" class="filter-input">
<option value="">全部原因</option>
<option v-for="reason in options.reasons" :key="reason" :value="reason">
{{ reason }}
</option>
</select>
</div>
<div class="filter-group filter-group-full">
<label class="filter-label">WORKCENTER GROUP</label>
<MultiSelect
:model-value="filters.workcenterGroups"
:options="options.workcenterGroups"
placeholder="全部工作中心群組"
searchable
@update:model-value="filters.workcenterGroups = $event"
/>
</div>
<div class="filter-toolbar">
<div class="checkbox-row">
<label class="checkbox-pill">
<input v-model="filters.includeExcludedScrap" type="checkbox" />
納入不計良率報廢
</label>
<label class="checkbox-pill">
<input v-model="filters.excludeMaterialScrap" type="checkbox" />
排除原物料報廢
</label>
<label class="checkbox-pill">
<input v-model="filters.excludePbDiode" type="checkbox" />
排除 PB_Diode
</label>
<label class="checkbox-pill">
<input
:checked="filters.paretoTop80"
type="checkbox"
@change="$emit('pareto-scope-toggle', $event.target.checked)"
/>
Pareto 僅顯示累計前 80%
</label>
</div>
<div class="filter-actions">
<button class="btn btn-primary" :disabled="loading.querying" @click="$emit('apply')">查詢</button>
<button class="btn btn-secondary" :disabled="loading.querying" @click="$emit('clear')">清除條件</button>
<button class="btn btn-light btn-export" :disabled="loading.querying" @click="$emit('export-csv')">匯出 CSV</button>
</div>
</div>
</div>
<div class="card-body active-filter-chip-row" v-if="activeFilterChips.length > 0">
<div class="filter-label">套用中篩選</div>
<div class="chip-list">
<div v-for="chip in activeFilterChips" :key="chip.key" class="filter-chip">
<span>{{ chip.label }}</span>
<button
v-if="chip.removable"
type="button"
class="chip-remove"
@click="$emit('remove-chip', chip)"
>
×
</button>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,167 @@
<script setup>
import { computed } from 'vue';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
const props = defineProps({
items: { type: Array, default: () => [] },
detailReason: { type: String, default: '' },
selectedDates: { type: Array, default: () => [] },
metricLabel: { type: String, default: '報廢量' },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['reason-click']);
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatPct(value) {
return `${Number(value || 0).toFixed(2)}%`;
}
const chartOption = computed(() => {
const items = props.items || [];
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter(params) {
const idx = Number(params?.[0]?.dataIndex || 0);
const item = items[idx] || {};
return [
`<b>${item.reason || '(未填寫)'}</b>`,
`${props.metricLabel}: ${formatNumber(item.metric_value || 0)}`,
`占比: ${Number(item.pct || 0).toFixed(2)}%`,
`累計: ${Number(item.cumPct || 0).toFixed(2)}%`,
].join('<br/>');
},
},
legend: {
data: [props.metricLabel, '累積%'],
bottom: 0,
},
grid: {
left: 52,
right: 52,
top: 20,
bottom: 96,
},
xAxis: {
type: 'category',
data: items.map((item) => item.reason || '(未填寫)'),
axisLabel: {
interval: 0,
rotate: items.length > 6 ? 35 : 0,
fontSize: 11,
overflow: 'truncate',
width: 100,
},
},
yAxis: [
{
type: 'value',
name: '量',
},
{
type: 'value',
name: '%',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%' },
},
],
series: [
{
name: props.metricLabel,
type: 'bar',
data: items.map((item) => Number(item.metric_value || 0)),
barMaxWidth: 34,
itemStyle: {
color(params) {
const reason = items[params.dataIndex]?.reason || '';
return reason === props.detailReason ? '#b91c1c' : '#2563eb';
},
borderRadius: [4, 4, 0, 0],
},
},
{
name: '累積%',
type: 'line',
yAxisIndex: 1,
data: items.map((item) => Number(item.cumPct || 0)),
lineStyle: { color: '#f59e0b', width: 2 },
itemStyle: { color: '#f59e0b' },
symbolSize: 6,
},
],
};
});
function handleChartClick(params) {
if (params?.seriesType !== 'bar') {
return;
}
const reason = props.items?.[params.dataIndex]?.reason;
if (reason) {
emit('reason-click', reason);
}
}
</script>
<template>
<section class="card">
<div class="card-header pareto-header">
<div class="card-title">
{{ metricLabel }} vs 報廢原因Pareto
<span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span>
</div>
</div>
<div class="card-body pareto-layout">
<div class="pareto-chart-wrap">
<VChart :option="chartOption" autoresize @click="handleChartClick" />
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
</div>
<div class="pareto-table-wrap">
<table class="detail-table pareto-table">
<thead>
<tr>
<th>原因</th>
<th>{{ metricLabel }}</th>
<th>占比</th>
<th>累積</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.reason"
:class="{ active: detailReason === item.reason }"
>
<td>
<button class="reason-link" type="button" @click="$emit('reason-click', item.reason)">
{{ item.reason }}
</button>
</td>
<td>{{ formatNumber(item.metric_value) }}</td>
<td>{{ formatPct(item.pct) }}</td>
<td>{{ formatPct(item.cumPct) }}</td>
</tr>
<tr v-if="!items || items.length === 0">
<td colspan="4" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
defineProps({
cards: { type: Array, default: () => [] },
});
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatPct(value) {
return `${Number(value || 0).toFixed(2)}%`;
}
</script>
<template>
<section class="summary-row reject-summary-row">
<article
v-for="card in cards"
:key="card.key"
class="summary-card"
:class="`lane-${card.lane}`"
>
<div class="summary-label">{{ card.label }}</div>
<div class="summary-value small">{{ card.isPct ? formatPct(card.value) : formatNumber(card.value) }}</div>
</article>
</section>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { BarChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, GridComponent, LegendComponent, TooltipComponent]);
const props = defineProps({
items: { type: Array, default: () => [] },
selectedDates: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['date-click', 'legend-change']);
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
const chartOption = computed(() => {
const items = props.items || [];
const dateSet = props.selectedDates.length > 0 ? new Set(props.selectedDates) : null;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: {
data: ['扣帳報廢量', '不扣帳報廢量'],
bottom: 0,
},
grid: { left: 48, right: 24, top: 22, bottom: 70 },
xAxis: {
type: 'category',
data: items.map((item) => item.bucket_date || ''),
},
yAxis: {
type: 'value',
axisLabel: {
formatter(value) {
return Number(value || 0).toLocaleString('zh-TW');
},
},
},
series: [
{
name: '扣帳報廢量',
type: 'bar',
color: '#dc2626',
data: items.map((item) => Number(item.REJECT_TOTAL_QTY || 0)),
itemStyle: {
color(params) {
const date = items[params.dataIndex]?.bucket_date || '';
return dateSet && !dateSet.has(date) ? '#f9a8a8' : '#dc2626';
},
},
barMaxWidth: 28,
},
{
name: '不扣帳報廢量',
type: 'bar',
color: '#0284c7',
data: items.map((item) => Number(item.DEFECT_QTY || 0)),
itemStyle: {
color(params) {
const date = items[params.dataIndex]?.bucket_date || '';
return dateSet && !dateSet.has(date) ? '#a5d8f0' : '#0284c7';
},
},
barMaxWidth: 28,
},
],
};
});
function handleChartClick(params) {
if (params?.componentType !== 'series') {
return;
}
const date = props.items?.[params.dataIndex]?.bucket_date;
if (date) {
emit('date-click', date);
}
}
function handleLegendChange(params) {
if (params?.selected) {
emit('legend-change', { ...params.selected });
}
}
</script>
<template>
<section class="chart-grid">
<article class="card">
<div class="card-header"><div class="card-title">報廢量趨勢</div></div>
<div class="card-body chart-wrap">
<VChart :option="chartOption" autoresize @click="handleChartClick" @legendselectchanged="handleLegendChange" />
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
</div>
</article>
</section>
</template>

View File

@@ -52,6 +52,10 @@
grid-column: span 2;
}
.filter-group-full {
grid-column: 1 / -1;
}
.filter-label {
font-size: 12px;
font-weight: 700;
@@ -78,12 +82,17 @@
min-width: 0;
}
.inline-toggle-group {
align-self: center;
.filter-toolbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.checkbox-row {
display: inline-flex;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
@@ -111,8 +120,7 @@
.filter-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
grid-column: span 2;
flex-shrink: 0;
}
.active-filter-chip-row {
@@ -194,6 +202,17 @@
gap: 12px;
}
.pareto-date-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: #dbeafe;
color: #1e40af;
font-size: 12px;
font-weight: 600;
}
.pareto-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
@@ -226,6 +245,10 @@
z-index: 1;
}
.detail-table tbody tr:hover {
background: #f8fbff;
}
.reason-link {
border: none;
background: transparent;
@@ -243,7 +266,39 @@
overflow: auto;
}
/* ---- MultiSelect component styles (shared-ui compatible) ---- */
.detail-table .cell-wrap {
white-space: normal;
max-width: 220px;
word-break: break-all;
}
.detail-reason-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 10px;
padding: 2px 10px;
border-radius: 999px;
background: #fef2f2;
color: #991b1b;
font-size: 12px;
font-weight: 600;
}
.badge-clear {
border: 0;
background: transparent;
color: #991b1b;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
/* ---- MultiSelect component styles ----
Duplicated from resource-shared/styles.css because this page imports
wip-shared/styles.css instead. Cannot import resource-shared directly
due to conflicting global class names (.dashboard, .btn, etc.).
TODO: Add <style> block to MultiSelect.vue to eliminate this duplication. ---- */
.multi-select {
position: relative;
@@ -387,11 +442,6 @@
grid-column: span 2;
}
.filter-actions {
grid-column: span 2;
justify-content: flex-start;
}
.pareto-layout {
grid-template-columns: 1fr;
}
@@ -406,11 +456,15 @@
grid-template-columns: 1fr;
}
.filter-group-wide,
.filter-actions {
.filter-group-wide {
grid-column: span 1;
}
.filter-toolbar {
flex-direction: column;
align-items: flex-start;
}
.checkbox-row {
flex-direction: column;
align-items: flex-start;

View File

@@ -1,9 +1,9 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import { replaceRuntimeHistory, toRuntimeRoute } from '../core/shell-navigation.js';
import { buildWipDetailQueryParams } from '../core/wip-derive.js';
import { buildWipDetailQueryParams, buildWipOverviewQueryParams } from '../core/wip-derive.js';
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
import FilterPanel from './components/FilterPanel.vue';
@@ -13,15 +13,27 @@ import SummaryCards from './components/SummaryCards.vue';
const API_TIMEOUT = 60000;
const PAGE_SIZE = 100;
const FILTER_OPTION_DEBOUNCE_MS = 120;
const workcenter = ref('');
const page = ref(1);
const filters = reactive({
workorder: '',
lotid: '',
package: '',
type: '',
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
const filterOptions = ref({
workorders: [],
lotids: [],
packages: [],
types: [],
firstnames: [],
waferdescs: [],
});
const activeStatusFilter = ref(null);
const detailData = ref(null);
@@ -33,6 +45,9 @@ const refreshError = ref(false);
const errorMessage = ref('');
const selectedLotId = ref('');
let filterOptionsDebounceTimer = null;
let filterOptionsRequestToken = 0;
function unwrapApiResult(result, fallbackMessage) {
if (result?.success) {
return result.data;
@@ -50,6 +65,35 @@ function getUrlParam(name) {
return new URLSearchParams(window.location.search).get(name)?.trim() || '';
}
function parseCsvParam(name) {
const raw = getUrlParam(name);
if (!raw) {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function normalizeArrayValues(values) {
if (!values) {
return [];
}
if (Array.isArray(values)) {
return values.map((value) => String(value).trim()).filter(Boolean);
}
return String(values)
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function serializeFilterValue(values) {
const normalized = normalizeArrayValues(values);
return normalized.join(',');
}
function updateUrlState() {
if (!workcenter.value) {
return;
@@ -58,17 +102,30 @@ function updateUrlState() {
const params = new URLSearchParams();
params.set('workcenter', workcenter.value);
if (filters.workorder) {
params.set('workorder', filters.workorder);
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.set('workorder', workorder);
}
if (filters.lotid) {
params.set('lotid', filters.lotid);
if (lotid) {
params.set('lotid', lotid);
}
if (filters.package) {
params.set('package', filters.package);
if (pkg) {
params.set('package', pkg);
}
if (filters.type) {
params.set('type', filters.type);
if (type) {
params.set('type', type);
}
if (firstname) {
params.set('firstname', firstname);
}
if (waferdesc) {
params.set('waferdesc', waferdesc);
}
if (activeStatusFilter.value) {
params.set('status', activeStatusFilter.value);
@@ -105,6 +162,51 @@ async function fetchDetail(signal) {
return unwrapApiResult(result, 'Failed to fetch detail');
}
async function loadFilterOptions(sourceFilters = filters) {
const requestToken = ++filterOptionsRequestToken;
try {
const params = buildWipOverviewQueryParams(sourceFilters);
const result = await apiGet('/api/wip/meta/filter-options', {
params,
timeout: API_TIMEOUT,
silent: true,
});
const data = unwrapApiResult(result, '載入篩選選項失敗');
if (requestToken !== filterOptionsRequestToken) {
return;
}
filterOptions.value = {
workorders: Array.isArray(data?.workorders) ? data.workorders : [],
lotids: Array.isArray(data?.lotids) ? data.lotids : [],
packages: Array.isArray(data?.packages) ? data.packages : [],
types: Array.isArray(data?.types) ? data.types : [],
firstnames: Array.isArray(data?.firstnames) ? data.firstnames : [],
waferdescs: Array.isArray(data?.waferdescs) ? data.waferdescs : [],
};
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('載入 WIP Detail 篩選選項失敗:', error);
}
}
}
function scheduleFilterOptionsReload(nextDraftFilters) {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
}
filterOptionsDebounceTimer = setTimeout(() => {
void loadFilterOptions(nextDraftFilters);
}, FILTER_OPTION_DEBOUNCE_MS);
}
function onFilterDraftChange(nextDraftFilters) {
scheduleFilterOptionsReload(nextDraftFilters);
}
function showRefreshSuccess() {
refreshSuccess.value = true;
setTimeout(() => {
@@ -112,7 +214,7 @@ function showRefreshSuccess() {
}, 1500);
}
const { createAbortSignal, triggerRefresh, startAutoRefresh, resetAutoRefresh } = useAutoRefresh({
const { createAbortSignal, triggerRefresh, startAutoRefresh } = useAutoRefresh({
onRefresh: () => loadAllData(false),
autoStart: false,
});
@@ -190,17 +292,30 @@ const tableData = computed(() => ({
const backUrl = computed(() => {
const params = new URLSearchParams();
if (filters.workorder) {
params.set('workorder', filters.workorder);
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.set('workorder', workorder);
}
if (filters.lotid) {
params.set('lotid', filters.lotid);
if (lotid) {
params.set('lotid', lotid);
}
if (filters.package) {
params.set('package', filters.package);
if (pkg) {
params.set('package', pkg);
}
if (filters.type) {
params.set('type', filters.type);
if (type) {
params.set('type', type);
}
if (firstname) {
params.set('firstname', firstname);
}
if (waferdesc) {
params.set('waferdesc', waferdesc);
}
if (activeStatusFilter.value) {
params.set('status', activeStatusFilter.value);
@@ -211,24 +326,37 @@ const backUrl = computed(() => {
});
function updateFilters(nextFilters) {
filters.workorder = nextFilters.workorder || '';
filters.lotid = nextFilters.lotid || '';
filters.package = nextFilters.package || '';
filters.type = nextFilters.type || '';
filters.workorder = normalizeArrayValues(nextFilters.workorder);
filters.lotid = normalizeArrayValues(nextFilters.lotid);
filters.package = normalizeArrayValues(nextFilters.package);
filters.type = normalizeArrayValues(nextFilters.type);
filters.firstname = normalizeArrayValues(nextFilters.firstname);
filters.waferdesc = normalizeArrayValues(nextFilters.waferdesc);
}
function applyFilters(nextFilters) {
updateFilters(nextFilters);
page.value = 1;
selectedLotId.value = '';
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
function clearFilters() {
updateFilters({ workorder: '', lotid: '', package: '', type: '' });
updateFilters({
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
activeStatusFilter.value = null;
page.value = 1;
selectedLotId.value = '';
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
@@ -274,10 +402,14 @@ async function manualRefresh() {
async function initializePage() {
workcenter.value = getUrlParam('workcenter');
filters.workorder = getUrlParam('workorder');
filters.lotid = getUrlParam('lotid');
filters.package = getUrlParam('package');
filters.type = getUrlParam('type');
updateFilters({
workorder: parseCsvParam('workorder'),
lotid: parseCsvParam('lotid'),
package: parseCsvParam('package'),
type: parseCsvParam('type'),
firstname: parseCsvParam('firstname'),
waferdesc: parseCsvParam('waferdesc'),
});
activeStatusFilter.value = getUrlParam('status') || null;
if (!workcenter.value) {
@@ -301,11 +433,21 @@ async function initializePage() {
return;
}
await loadAllData(true);
await Promise.all([
loadFilterOptions(filters),
loadAllData(true),
]);
startAutoRefresh();
}
void initializePage();
onBeforeUnmount(() => {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
filterOptionsDebounceTimer = null;
}
});
</script>
<template>
@@ -328,7 +470,14 @@ void initializePage();
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<FilterPanel :filters="filters" @apply="applyFilters" @clear="clearFilters" />
<FilterPanel
:filters="filters"
:options="filterOptions"
:loading="refreshing"
@apply="applyFilters"
@clear="clearFilters"
@draft-change="onFilterDraftChange"
/>
<SummaryCards
:summary="summary"

View File

@@ -1,71 +1,100 @@
<script setup>
import { reactive, watch } from 'vue';
import { apiGet } from '../../core/api.js';
import { useAutocomplete } from '../../shared-composables/useAutocomplete.js';
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
const props = defineProps({
filters: {
type: Object,
required: true,
},
options: {
type: Object,
default: () => ({}),
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['apply', 'clear']);
const emit = defineEmits(['apply', 'clear', 'draft-change']);
const fields = [
{ key: 'workorder', label: 'WORKORDER', optionKey: 'workorders', placeholder: 'All WORKORDER' },
{ key: 'lotid', label: 'LOT ID', optionKey: 'lotids', placeholder: 'All LOT ID' },
{ key: 'package', label: 'PACKAGE', optionKey: 'packages', placeholder: 'All PACKAGE' },
{ key: 'type', label: 'TYPE', optionKey: 'types', placeholder: 'All TYPE' },
{ key: 'firstname', label: 'Wafer LOT', optionKey: 'firstnames', placeholder: 'All Wafer LOT' },
{ key: 'waferdesc', label: 'Wafer Type', optionKey: 'waferdescs', placeholder: 'All Wafer Type' },
];
const draft = reactive({
workorder: '',
lotid: '',
package: '',
type: '',
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
function toArray(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
return String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function cloneDraft() {
return {
workorder: [...draft.workorder],
lotid: [...draft.lotid],
package: [...draft.package],
type: [...draft.type],
firstname: [...draft.firstname],
waferdesc: [...draft.waferdesc],
};
}
watch(
() => props.filters,
(nextFilters) => {
draft.workorder = nextFilters.workorder || '';
draft.lotid = nextFilters.lotid || '';
draft.package = nextFilters.package || '';
draft.type = nextFilters.type || '';
draft.workorder = toArray(nextFilters.workorder);
draft.lotid = toArray(nextFilters.lotid);
draft.package = toArray(nextFilters.package);
draft.type = toArray(nextFilters.type);
draft.firstname = toArray(nextFilters.firstname);
draft.waferdesc = toArray(nextFilters.waferdesc);
},
{ immediate: true, deep: true }
);
const { ensureField, handleInput, handleFocus, handleBlur, selectItem } = useAutocomplete({
getFilters: () => ({ ...draft }),
request: (url, options) => apiGet(url, options),
debounceMs: 300,
});
const fields = [
{ key: 'workorder', label: 'WORKORDER', placeholder: 'Search...' },
{ key: 'lotid', label: 'LOT ID', placeholder: 'Search...' },
{ key: 'package', label: 'PACKAGE', placeholder: 'Search...' },
{ key: 'type', label: 'TYPE', placeholder: 'Search...' },
];
function getFieldState(field) {
return ensureField(field);
function getOptions(field) {
return Array.isArray(props.options?.[field.optionKey]) ? props.options[field.optionKey] : [];
}
function onInput(field, event) {
draft[field] = event.target.value;
handleInput(field, draft[field]);
}
function onSelect(field, value) {
draft[field] = selectItem(field, value);
function notifyDraftChange() {
emit('draft-change', cloneDraft());
}
function applyFilters() {
emit('apply', { ...draft });
emit('apply', cloneDraft());
}
function clearFilters() {
draft.workorder = '';
draft.lotid = '';
draft.package = '';
draft.type = '';
draft.workorder = [];
draft.lotid = [];
draft.package = [];
draft.type = [];
draft.firstname = [];
draft.waferdesc = [];
notifyDraftChange();
emit('clear');
}
</script>
@@ -74,37 +103,22 @@ function clearFilters() {
<section class="filters">
<div v-for="field in fields" :key="field.key" class="filter-group">
<label>{{ field.label }}</label>
<div class="autocomplete-container">
<input
type="text"
:value="draft[field.key]"
:placeholder="field.placeholder"
autocomplete="off"
@input="onInput(field.key, $event)"
@focus="handleFocus(field.key)"
@blur="handleBlur(field.key)"
@keydown.enter.prevent="applyFilters"
/>
<div class="autocomplete-dropdown" :class="{ show: getFieldState(field.key).open }">
<div
v-for="item in getFieldState(field.key).items"
:key="item"
class="autocomplete-item"
@mousedown.prevent="onSelect(field.key, item)"
>
{{ item }}
</div>
<div
v-if="!getFieldState(field.key).loading && getFieldState(field.key).open && getFieldState(field.key).items.length === 0"
class="autocomplete-empty"
>
No results
</div>
</div>
</div>
<MultiSelect
:model-value="draft[field.key]"
:options="getOptions(field)"
:disabled="loading"
:placeholder="field.placeholder"
searchable
@update:model-value="
draft[field.key] = $event;
notifyDraftChange();
"
/>
</div>
<button type="button" class="btn-primary" @click="applyFilters">Apply</button>
<button type="button" class="btn-secondary" @click="clearFilters">Clear</button>
<div class="filters-actions">
<button type="button" class="btn-primary" :disabled="loading" @click="applyFilters">套用篩選</button>
<button type="button" class="btn-secondary" :disabled="loading" @click="clearFilters">清除篩選</button>
</div>
</section>
</template>

View File

@@ -1,4 +1,5 @@
@import '../wip-shared/styles.css';
@import '../resource-shared/styles.css';
.wip-detail-page .header h1 {
font-size: 24px;
@@ -19,16 +20,17 @@
border-radius: 10px;
margin-bottom: 16px;
box-shadow: var(--shadow);
display: flex;
gap: 16px;
align-items: flex-end;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 14px 16px;
align-items: start;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-group label {
@@ -37,61 +39,17 @@
color: var(--muted);
}
.autocomplete-container {
position: relative;
display: inline-block;
.filters-actions {
grid-column: 1 / -1;
display: inline-flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.autocomplete-container input {
width: 180px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.autocomplete-container input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: #fff;
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
box-shadow: var(--shadow);
z-index: 20;
display: none;
}
.autocomplete-dropdown.show {
display: block;
}
.autocomplete-item,
.autocomplete-empty {
padding: 8px 12px;
font-size: 13px;
}
.autocomplete-item {
cursor: pointer;
}
.autocomplete-item:hover {
background: #f0f2ff;
}
.autocomplete-empty {
color: var(--muted);
.filters-actions .btn-primary,
.filters-actions .btn-secondary {
min-width: 108px;
}
.detail-summary-row {
@@ -457,6 +415,10 @@
}
@media (max-width: 1000px) {
.filters {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.detail-summary-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -464,13 +426,11 @@
@media (max-width: 768px) {
.filters {
flex-direction: column;
align-items: stretch;
grid-template-columns: 1fr;
}
.autocomplete-container,
.autocomplete-container input {
width: 100%;
.filters-actions {
justify-content: flex-start;
}
.detail-summary-row,

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import { navigateToRuntimeRoute, replaceRuntimeHistory } from '../core/shell-navigation.js';
@@ -16,15 +16,26 @@ import StatusCards from './components/StatusCards.vue';
import SummaryCards from './components/SummaryCards.vue';
const API_TIMEOUT = 60000;
const FILTER_OPTION_DEBOUNCE_MS = 120;
const summary = ref(null);
const matrix = ref(null);
const hold = ref(null);
const filterOptions = ref({
workorders: [],
lotids: [],
packages: [],
types: [],
firstnames: [],
waferdescs: [],
});
const filters = reactive({
workorder: '',
lotid: '',
package: '',
type: '',
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
const activeStatusFilter = ref(null);
@@ -33,6 +44,8 @@ const refreshing = ref(false);
const refreshSuccess = ref(false);
const refreshError = ref(false);
const errorMessage = ref('');
let filterOptionsDebounceTimer = null;
let filterOptionsRequestToken = 0;
function unwrapApiResult(result, fallbackMessage) {
if (result?.success) {
@@ -51,6 +64,35 @@ function getUrlParam(name) {
return new URLSearchParams(window.location.search).get(name)?.trim() || '';
}
function parseCsvParam(name) {
const raw = getUrlParam(name);
if (!raw) {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function normalizeArrayValues(values) {
if (!values) {
return [];
}
if (Array.isArray(values)) {
return values.map((value) => String(value).trim()).filter(Boolean);
}
return String(values)
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function serializeFilterValue(values) {
const normalized = normalizeArrayValues(values);
return normalized.join(',');
}
function buildFilters(status = null) {
return buildWipOverviewQueryParams(filters, status);
}
@@ -82,6 +124,51 @@ async function fetchHold(signal) {
return unwrapApiResult(result, 'Failed to fetch hold data');
}
async function loadFilterOptions(sourceFilters = filters) {
const requestToken = ++filterOptionsRequestToken;
try {
const params = buildWipOverviewQueryParams(sourceFilters);
const result = await apiGet('/api/wip/meta/filter-options', {
params,
timeout: API_TIMEOUT,
silent: true,
});
const data = unwrapApiResult(result, '載入篩選選項失敗');
if (requestToken !== filterOptionsRequestToken) {
return;
}
filterOptions.value = {
workorders: Array.isArray(data?.workorders) ? data.workorders : [],
lotids: Array.isArray(data?.lotids) ? data.lotids : [],
packages: Array.isArray(data?.packages) ? data.packages : [],
types: Array.isArray(data?.types) ? data.types : [],
firstnames: Array.isArray(data?.firstnames) ? data.firstnames : [],
waferdescs: Array.isArray(data?.waferdescs) ? data.waferdescs : [],
};
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('載入 WIP 篩選選項失敗:', error);
}
}
}
function scheduleFilterOptionsReload(nextDraftFilters) {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
}
filterOptionsDebounceTimer = setTimeout(() => {
void loadFilterOptions(nextDraftFilters);
}, FILTER_OPTION_DEBOUNCE_MS);
}
function onFilterDraftChange(nextDraftFilters) {
scheduleFilterOptionsReload(nextDraftFilters);
}
const lastUpdate = computed(() => {
return summary.value?.dataUpdateDate ? `Last Update: ${summary.value.dataUpdateDate}` : '';
});
@@ -103,7 +190,7 @@ const matrixTitle = computed(() => {
const splitHold = computed(() => splitHoldByType(hold.value));
const { createAbortSignal, resetAutoRefresh, triggerRefresh } = useAutoRefresh({
const { createAbortSignal, triggerRefresh } = useAutoRefresh({
onRefresh: () => loadAllData(false),
autoStart: true,
});
@@ -175,26 +262,41 @@ function toggleStatusFilter(status) {
}
function updateFilters(nextFilters) {
filters.workorder = nextFilters.workorder || '';
filters.lotid = nextFilters.lotid || '';
filters.package = nextFilters.package || '';
filters.type = nextFilters.type || '';
filters.workorder = normalizeArrayValues(nextFilters.workorder);
filters.lotid = normalizeArrayValues(nextFilters.lotid);
filters.package = normalizeArrayValues(nextFilters.package);
filters.type = normalizeArrayValues(nextFilters.type);
filters.firstname = normalizeArrayValues(nextFilters.firstname);
filters.waferdesc = normalizeArrayValues(nextFilters.waferdesc);
}
function updateUrlState() {
const params = new URLSearchParams();
if (filters.workorder) {
params.set('workorder', filters.workorder);
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.set('workorder', workorder);
}
if (filters.lotid) {
params.set('lotid', filters.lotid);
if (lotid) {
params.set('lotid', lotid);
}
if (filters.package) {
params.set('package', filters.package);
if (pkg) {
params.set('package', pkg);
}
if (filters.type) {
params.set('type', filters.type);
if (type) {
params.set('type', type);
}
if (firstname) {
params.set('firstname', firstname);
}
if (waferdesc) {
params.set('waferdesc', waferdesc);
}
if (activeStatusFilter.value) {
params.set('status', activeStatusFilter.value);
@@ -208,19 +310,22 @@ function updateUrlState() {
function applyFilters(nextFilters) {
updateFilters(nextFilters);
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
function clearFilters() {
updateFilters({ workorder: '', lotid: '', package: '', type: '' });
updateFilters({
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
activeStatusFilter.value = null;
updateUrlState();
void loadAllData(false);
}
function removeFilter(field) {
filters[field] = '';
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
@@ -228,17 +333,30 @@ function navigateToDetail(workcenter) {
const params = new URLSearchParams();
params.append('workcenter', workcenter);
if (filters.workorder) {
params.append('workorder', filters.workorder);
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.append('workorder', workorder);
}
if (filters.lotid) {
params.append('lotid', filters.lotid);
if (lotid) {
params.append('lotid', lotid);
}
if (filters.package) {
params.append('package', filters.package);
if (pkg) {
params.append('package', pkg);
}
if (filters.type) {
params.append('type', filters.type);
if (type) {
params.append('type', type);
}
if (firstname) {
params.append('firstname', firstname);
}
if (waferdesc) {
params.append('waferdesc', waferdesc);
}
if (activeStatusFilter.value) {
params.append('status', activeStatusFilter.value);
@@ -259,18 +377,32 @@ async function manualRefresh() {
}
async function initializePage() {
filters.workorder = getUrlParam('workorder');
filters.lotid = getUrlParam('lotid');
filters.package = getUrlParam('package');
filters.type = getUrlParam('type');
updateFilters({
workorder: parseCsvParam('workorder'),
lotid: parseCsvParam('lotid'),
package: parseCsvParam('package'),
type: parseCsvParam('type'),
firstname: parseCsvParam('firstname'),
waferdesc: parseCsvParam('waferdesc'),
});
activeStatusFilter.value = getUrlParam('status') || null;
await loadAllData(true);
await Promise.all([
loadFilterOptions(filters),
loadAllData(true),
]);
}
onMounted(() => {
void initializePage();
});
onBeforeUnmount(() => {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
filterOptionsDebounceTimer = null;
}
});
</script>
<template>
@@ -292,9 +424,11 @@ onMounted(() => {
<FilterPanel
:filters="filters"
:options="filterOptions"
:loading="refreshing"
@apply="applyFilters"
@clear="clearFilters"
@remove="removeFilter"
@draft-change="onFilterDraftChange"
/>
<SummaryCards :summary="summary" />

View File

@@ -1,133 +1,125 @@
<script setup>
import { reactive, watch } from 'vue';
import { apiGet } from '../../core/api.js';
import { useAutocomplete } from '../../shared-composables/useAutocomplete.js';
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
const props = defineProps({
filters: {
type: Object,
required: true,
},
options: {
type: Object,
default: () => ({}),
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['apply', 'clear', 'remove']);
const emit = defineEmits(['apply', 'clear', 'draft-change']);
const fields = [
{ key: 'workorder', label: 'WORKORDER', optionKey: 'workorders', placeholder: '全部 WORKORDER' },
{ key: 'lotid', label: 'LOT ID', optionKey: 'lotids', placeholder: '全部 LOT ID' },
{ key: 'package', label: 'PACKAGE', optionKey: 'packages', placeholder: '全部 PACKAGE' },
{ key: 'type', label: 'TYPE', optionKey: 'types', placeholder: '全部 TYPE' },
{ key: 'firstname', label: 'Wafer LOT', optionKey: 'firstnames', placeholder: '全部 Wafer LOT' },
{ key: 'waferdesc', label: 'Wafer Type', optionKey: 'waferdescs', placeholder: '全部 Wafer Type' },
];
const draft = reactive({
workorder: '',
lotid: '',
package: '',
type: '',
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
function toArray(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
return String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function cloneDraft() {
return {
workorder: [...draft.workorder],
lotid: [...draft.lotid],
package: [...draft.package],
type: [...draft.type],
firstname: [...draft.firstname],
waferdesc: [...draft.waferdesc],
};
}
watch(
() => props.filters,
(nextFilters) => {
draft.workorder = nextFilters.workorder || '';
draft.lotid = nextFilters.lotid || '';
draft.package = nextFilters.package || '';
draft.type = nextFilters.type || '';
draft.workorder = toArray(nextFilters.workorder);
draft.lotid = toArray(nextFilters.lotid);
draft.package = toArray(nextFilters.package);
draft.type = toArray(nextFilters.type);
draft.firstname = toArray(nextFilters.firstname);
draft.waferdesc = toArray(nextFilters.waferdesc);
},
{ immediate: true, deep: true }
);
const { ensureField, handleInput, handleFocus, handleBlur, selectItem } = useAutocomplete({
getFilters: () => ({ ...draft }),
request: (url, options) => apiGet(url, options),
debounceMs: 300,
});
const fields = [
{ key: 'workorder', label: 'WORKORDER', placeholder: '輸入 WORKORDER...' },
{ key: 'lotid', label: 'LOT ID', placeholder: '輸入 LOT ID...' },
{ key: 'package', label: 'PACKAGE', placeholder: '輸入 PACKAGE...' },
{ key: 'type', label: 'TYPE', placeholder: '輸入 TYPE...' },
];
function getFieldState(field) {
return ensureField(field);
function getOptions(field) {
return Array.isArray(props.options?.[field.optionKey]) ? props.options[field.optionKey] : [];
}
function applyFilters() {
emit('apply', { ...draft });
emit('apply', cloneDraft());
}
function notifyDraftChange() {
emit('draft-change', cloneDraft());
}
function clearFilters() {
draft.workorder = '';
draft.lotid = '';
draft.package = '';
draft.type = '';
draft.workorder = [];
draft.lotid = [];
draft.package = [];
draft.type = [];
draft.firstname = [];
draft.waferdesc = [];
notifyDraftChange();
emit('clear');
}
function removeFilter(field) {
draft[field] = '';
emit('remove', field);
}
function onInput(field, event) {
draft[field] = event.target.value;
handleInput(field, draft[field]);
}
function onSelect(field, value) {
draft[field] = selectItem(field, value);
}
</script>
<template>
<section class="filters">
<div v-for="field in fields" :key="field.key" class="filter-group">
<label>{{ field.label }}</label>
<input
type="text"
:value="draft[field.key]"
<MultiSelect
:model-value="draft[field.key]"
:options="getOptions(field)"
:disabled="loading"
:placeholder="field.placeholder"
autocomplete="off"
@input="onInput(field.key, $event)"
@focus="handleFocus(field.key)"
@blur="handleBlur(field.key)"
@keydown.enter.prevent="applyFilters"
searchable
@update:model-value="
draft[field.key] = $event;
notifyDraftChange();
"
/>
<span class="search-loading" :class="{ active: getFieldState(field.key).loading }"></span>
<div class="autocomplete-dropdown" :class="{ active: getFieldState(field.key).open }">
<div
v-for="item in getFieldState(field.key).items"
:key="item"
class="autocomplete-item"
@mousedown.prevent="onSelect(field.key, item)"
>
{{ item }}
</div>
<div
v-if="!getFieldState(field.key).loading && getFieldState(field.key).open && getFieldState(field.key).items.length === 0"
class="autocomplete-item no-results"
>
無符合結果
</div>
</div>
</div>
<button type="button" class="btn-primary" @click="applyFilters">套用篩選</button>
<button type="button" class="btn-secondary" @click="clearFilters">清除篩選</button>
<TransitionGroup name="filter-chip" tag="div" class="active-filters">
<span v-if="filters.workorder" key="workorder" class="filter-tag">
WO: {{ filters.workorder }}
<span class="remove" @click="removeFilter('workorder')">×</span>
</span>
<span v-if="filters.lotid" key="lotid" class="filter-tag">
Lot: {{ filters.lotid }}
<span class="remove" @click="removeFilter('lotid')">×</span>
</span>
<span v-if="filters.package" key="package" class="filter-tag">
Pkg: {{ filters.package }}
<span class="remove" @click="removeFilter('package')">×</span>
</span>
<span v-if="filters.type" key="type" class="filter-tag">
Type: {{ filters.type }}
<span class="remove" @click="removeFilter('type')">×</span>
</span>
</TransitionGroup>
<div class="filters-actions">
<button type="button" class="btn-primary" :disabled="loading" @click="applyFilters">套用篩選</button>
<button type="button" class="btn-secondary" :disabled="loading" @click="clearFilters">清除篩選</button>
</div>
</section>
</template>

View File

@@ -1,4 +1,5 @@
@import '../wip-shared/styles.css';
@import '../resource-shared/styles.css';
.wip-overview-page .header h1 {
font-size: 24px;
@@ -19,17 +20,17 @@
border-radius: 10px;
margin-bottom: 16px;
box-shadow: var(--shadow);
display: flex;
gap: 16px;
align-items: flex-end;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 14px 16px;
align-items: start;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
min-width: 0;
}
.filter-group label {
@@ -38,109 +39,17 @@
color: var(--muted);
}
.filter-group input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
min-width: 200px;
}
.filter-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.search-loading {
position: absolute;
right: 10px;
top: 32px;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: none;
}
.search-loading.active {
display: block;
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: var(--shadow);
max-height: 200px;
overflow-y: auto;
z-index: 20;
display: none;
}
.autocomplete-dropdown.active {
display: block;
}
.autocomplete-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.autocomplete-item:hover {
background: #f3f4f6;
}
.autocomplete-item.no-results {
color: var(--muted);
cursor: default;
}
.active-filters {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-tag {
.filters-actions {
grid-column: 1 / -1;
display: inline-flex;
gap: 10px;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #e8ecff;
color: var(--primary);
border-radius: 4px;
font-size: 12px;
justify-content: flex-end;
}
.filter-tag .remove {
cursor: pointer;
font-weight: 700;
}
.filter-chip-enter-active,
.filter-chip-leave-active {
transition: all 0.2s ease;
}
.filter-chip-enter-from,
.filter-chip-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
.filter-chip-enter-active,
.filter-chip-leave-active {
transition: none !important;
}
.filters-actions .btn-primary,
.filters-actions .btn-secondary {
min-width: 108px;
}
.overview-summary-row {
@@ -487,11 +396,7 @@
@media (max-width: 1000px) {
.filters {
gap: 12px;
}
.filter-group input {
min-width: 180px;
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.wip-status-row {
@@ -501,13 +406,11 @@
@media (max-width: 768px) {
.filters {
flex-direction: column;
align-items: stretch;
grid-template-columns: 1fr;
}
.filter-group input {
width: 100%;
min-width: 0;
.filters-actions {
justify-content: flex-start;
}
.overview-summary-row,

View File

@@ -10,16 +10,20 @@ import {
test('buildWipOverviewQueryParams keeps only non-empty filters', () => {
const params = buildWipOverviewQueryParams({
workorder: ' WO-1 ',
lotid: '',
package: 'PKG-A',
workorder: [' WO-1 ', 'WO-2'],
lotid: [],
package: ['PKG-A'],
type: 'QFN',
firstname: ['WF-01'],
waferdesc: 'SiC',
});
assert.deepEqual(params, {
workorder: 'WO-1',
workorder: 'WO-1,WO-2',
package: 'PKG-A',
type: 'QFN',
firstname: 'WF-01',
waferdesc: 'SiC',
});
});