feat: polish reject history UI and enhance WIP filter interactions
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
85
frontend/src/reject-history/components/DetailTable.vue
Normal file
85
frontend/src/reject-history/components/DetailTable.vue
Normal 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>
|
||||
108
frontend/src/reject-history/components/FilterPanel.vue
Normal file
108
frontend/src/reject-history/components/FilterPanel.vue
Normal 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>
|
||||
167
frontend/src/reject-history/components/ParetoSection.vue
Normal file
167
frontend/src/reject-history/components/ParetoSection.vue
Normal 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>
|
||||
27
frontend/src/reject-history/components/SummaryCards.vue
Normal file
27
frontend/src/reject-history/components/SummaryCards.vue
Normal 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>
|
||||
105
frontend/src/reject-history/components/TrendChart.vue
Normal file
105
frontend/src/reject-history/components/TrendChart.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user