feat(reject-history): multi-pareto 3×2 grid with cross-filter linkage
Replace single-dimension Pareto dropdown with 6 simultaneous Pareto charts (不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a responsive 3-column grid. Clicking items in one Pareto cross-filters the other 5 (exclude-self logic), and the detail table applies all dimension selections with AND logic. Backend: - Add batch-pareto endpoint (cache-only, no Oracle queries) - Add _apply_cross_filter() with exclude-self pattern - Extend view/export endpoints for multi-dimension sel_* params Frontend: - New ParetoGrid.vue wrapping 6 ParetoSection instances - Simplify ParetoSection: remove dimension dropdown, keep TOP20 toggle - Replace single-dimension state with paretoSelections reactive object - Adaptive x-axis labels (font size, rotation, hideOverlap) for compact grid - Responsive grid: 3-col desktop, 2-col tablet, 1-col mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -173,8 +173,7 @@ export function buildViewParams(queryId, {
|
||||
metricFilter = 'all',
|
||||
trendDates = [],
|
||||
detailReason = '',
|
||||
paretoDimension = '',
|
||||
paretoValues = [],
|
||||
paretoSelections = {},
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
policyFilters = {},
|
||||
@@ -198,13 +197,19 @@ export function buildViewParams(queryId, {
|
||||
if (detailReason) {
|
||||
params.detail_reason = detailReason;
|
||||
}
|
||||
const normalizedParetoDimension = normalizeText(paretoDimension).toLowerCase();
|
||||
const normalizedParetoValues = normalizeArray(paretoValues);
|
||||
if (normalizedParetoDimension) {
|
||||
params.pareto_dimension = normalizedParetoDimension;
|
||||
}
|
||||
if (normalizedParetoValues.length > 0) {
|
||||
params.pareto_values = normalizedParetoValues;
|
||||
const selectionParamMap = {
|
||||
reason: 'sel_reason',
|
||||
package: 'sel_package',
|
||||
type: 'sel_type',
|
||||
workflow: 'sel_workflow',
|
||||
workcenter: 'sel_workcenter',
|
||||
equipment: 'sel_equipment',
|
||||
};
|
||||
for (const [dimension, paramName] of Object.entries(selectionParamMap)) {
|
||||
const normalizedValues = normalizeArray(paretoSelections?.[dimension]);
|
||||
if (normalizedValues.length > 0) {
|
||||
params[paramName] = normalizedValues;
|
||||
}
|
||||
}
|
||||
params.page = page || 1;
|
||||
params.per_page = perPage || 50;
|
||||
|
||||
@@ -10,22 +10,64 @@ import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
|
||||
import DetailTable from './components/DetailTable.vue';
|
||||
import FilterPanel from './components/FilterPanel.vue';
|
||||
import ParetoSection from './components/ParetoSection.vue';
|
||||
import ParetoGrid from './components/ParetoGrid.vue';
|
||||
import SummaryCards from './components/SummaryCards.vue';
|
||||
import TrendChart from './components/TrendChart.vue';
|
||||
|
||||
const API_TIMEOUT = 360000;
|
||||
const DEFAULT_PER_PAGE = 50;
|
||||
const PARETO_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']);
|
||||
const PARETO_DIMENSION_LABELS = {
|
||||
reason: '不良原因',
|
||||
package: 'PACKAGE',
|
||||
type: 'TYPE',
|
||||
workflow: 'WORKFLOW',
|
||||
workcenter: '站點',
|
||||
equipment: '機台',
|
||||
const PARETO_DIMENSIONS = ['reason', 'package', 'type', 'workflow', 'workcenter', 'equipment'];
|
||||
const PARETO_SELECTION_PARAM_MAP = {
|
||||
reason: 'sel_reason',
|
||||
package: 'sel_package',
|
||||
type: 'sel_type',
|
||||
workflow: 'sel_workflow',
|
||||
workcenter: 'sel_workcenter',
|
||||
equipment: 'sel_equipment',
|
||||
};
|
||||
|
||||
function createEmptyParetoSelections() {
|
||||
return {
|
||||
reason: [],
|
||||
package: [],
|
||||
type: [],
|
||||
workflow: [],
|
||||
workcenter: [],
|
||||
equipment: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyParetoData() {
|
||||
return {
|
||||
reason: { items: [], dimension: 'reason', metric_mode: 'reject_total' },
|
||||
package: { items: [], dimension: 'package', metric_mode: 'reject_total' },
|
||||
type: { items: [], dimension: 'type', metric_mode: 'reject_total' },
|
||||
workflow: { items: [], dimension: 'workflow', metric_mode: 'reject_total' },
|
||||
workcenter: { items: [], dimension: 'workcenter', metric_mode: 'reject_total' },
|
||||
equipment: { items: [], dimension: 'equipment', metric_mode: 'reject_total' },
|
||||
};
|
||||
}
|
||||
|
||||
function getDimensionLabel(dimension) {
|
||||
switch (dimension) {
|
||||
case 'reason':
|
||||
return '不良原因';
|
||||
case 'package':
|
||||
return 'PACKAGE';
|
||||
case 'type':
|
||||
return 'TYPE';
|
||||
case 'workflow':
|
||||
return 'WORKFLOW';
|
||||
case 'workcenter':
|
||||
return '站點';
|
||||
case 'equipment':
|
||||
return '機台';
|
||||
default:
|
||||
return 'Pareto';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Primary query form state ----
|
||||
const queryMode = ref('date_range');
|
||||
const containerInputType = ref('lot');
|
||||
@@ -69,11 +111,9 @@ const supplementaryFilters = reactive({
|
||||
const page = ref(1);
|
||||
const selectedTrendDates = ref([]);
|
||||
const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true });
|
||||
const paretoDimension = ref('reason');
|
||||
const selectedParetoValues = ref([]);
|
||||
const paretoDisplayScope = ref('all');
|
||||
const dimensionParetoItems = ref([]);
|
||||
const dimensionParetoLoading = ref(false);
|
||||
const paretoSelections = reactive(createEmptyParetoSelections());
|
||||
const paretoData = reactive(createEmptyParetoData());
|
||||
|
||||
// ---- Data state ----
|
||||
const summary = ref({
|
||||
@@ -102,6 +142,7 @@ const loading = reactive({
|
||||
initial: false,
|
||||
querying: false,
|
||||
list: false,
|
||||
pareto: false,
|
||||
exporting: false,
|
||||
});
|
||||
const errorMessage = ref('');
|
||||
@@ -109,6 +150,7 @@ const lastQueryAt = ref('');
|
||||
|
||||
// ---- Request staleness tracking ----
|
||||
let activeRequestId = 0;
|
||||
let activeParetoRequestId = 0;
|
||||
|
||||
function nextRequestId() {
|
||||
activeRequestId += 1;
|
||||
@@ -119,6 +161,15 @@ function isStaleRequest(id) {
|
||||
return id !== activeRequestId;
|
||||
}
|
||||
|
||||
function nextParetoRequestId() {
|
||||
activeParetoRequestId += 1;
|
||||
return activeParetoRequestId;
|
||||
}
|
||||
|
||||
function isStaleParetoRequest(id) {
|
||||
return id !== activeParetoRequestId;
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
function toDateString(value) {
|
||||
const y = value.getFullYear();
|
||||
@@ -143,6 +194,10 @@ function metricFilterParam() {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function paretoMetricApiMode() {
|
||||
return paretoMetricMode.value === 'defect' ? 'defect' : 'reject_total';
|
||||
}
|
||||
|
||||
function unwrapApiResult(result, fallbackMessage) {
|
||||
if (result?.success === true) {
|
||||
return result;
|
||||
@@ -153,6 +208,93 @@ function unwrapApiResult(result, fallbackMessage) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function resetParetoSelections() {
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
paretoSelections[dimension] = [];
|
||||
}
|
||||
}
|
||||
|
||||
function resetParetoData() {
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
paretoData[dimension] = {
|
||||
items: [],
|
||||
dimension,
|
||||
metric_mode: paretoMetricApiMode(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildBatchParetoParams() {
|
||||
const params = {
|
||||
query_id: queryId.value,
|
||||
metric_mode: paretoMetricApiMode(),
|
||||
pareto_scope: committedPrimary.paretoTop80 ? 'top80' : 'all',
|
||||
pareto_display_scope: paretoDisplayScope.value,
|
||||
include_excluded_scrap: committedPrimary.includeExcludedScrap ? 'true' : 'false',
|
||||
exclude_material_scrap: committedPrimary.excludeMaterialScrap ? 'true' : 'false',
|
||||
exclude_pb_diode: committedPrimary.excludePbDiode ? 'true' : 'false',
|
||||
};
|
||||
|
||||
if (supplementaryFilters.packages.length > 0) {
|
||||
params.packages = supplementaryFilters.packages;
|
||||
}
|
||||
if (supplementaryFilters.workcenterGroups.length > 0) {
|
||||
params.workcenter_groups = supplementaryFilters.workcenterGroups;
|
||||
}
|
||||
if (supplementaryFilters.reason) {
|
||||
params.reason = supplementaryFilters.reason;
|
||||
}
|
||||
if (selectedTrendDates.value.length > 0) {
|
||||
params.trend_dates = selectedTrendDates.value;
|
||||
}
|
||||
for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) {
|
||||
if (paretoSelections[dimension]?.length > 0) {
|
||||
params[key] = paretoSelections[dimension];
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
async function fetchBatchPareto() {
|
||||
if (!queryId.value) return;
|
||||
|
||||
const requestId = nextParetoRequestId();
|
||||
loading.pareto = true;
|
||||
|
||||
try {
|
||||
const resp = await apiGet('/api/reject-history/batch-pareto', {
|
||||
params: buildBatchParetoParams(),
|
||||
timeout: API_TIMEOUT,
|
||||
});
|
||||
if (isStaleParetoRequest(requestId)) return;
|
||||
|
||||
if (resp?.success === false && resp?.error === 'cache_miss') {
|
||||
await executePrimaryQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = unwrapApiResult(resp, '查詢批次 Pareto 失敗');
|
||||
const dimensions = result.data?.dimensions || {};
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
paretoData[dimension] = dimensions[dimension] || {
|
||||
items: [],
|
||||
dimension,
|
||||
metric_mode: paretoMetricApiMode(),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (isStaleParetoRequest(requestId)) return;
|
||||
resetParetoData();
|
||||
if (error?.name !== 'AbortError') {
|
||||
errorMessage.value = error?.message || '查詢批次 Pareto 失敗';
|
||||
}
|
||||
} finally {
|
||||
if (!isStaleParetoRequest(requestId)) {
|
||||
loading.pareto = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Primary query (POST /query → Oracle → cache) ----
|
||||
async function executePrimaryQuery() {
|
||||
const requestId = nextRequestId();
|
||||
@@ -180,7 +322,6 @@ async function executePrimaryQuery() {
|
||||
|
||||
const result = unwrapApiResult(resp, '主查詢執行失敗');
|
||||
|
||||
// Commit primary params for URL state and chips
|
||||
committedPrimary.mode = queryMode.value;
|
||||
committedPrimary.startDate = draftFilters.startDate;
|
||||
committedPrimary.endDate = draftFilters.endDate;
|
||||
@@ -192,7 +333,6 @@ async function executePrimaryQuery() {
|
||||
committedPrimary.excludePbDiode = draftFilters.excludePbDiode;
|
||||
committedPrimary.paretoTop80 = draftFilters.paretoTop80;
|
||||
|
||||
// Store query result
|
||||
queryId.value = result.query_id;
|
||||
resolutionInfo.value = result.resolution_info || null;
|
||||
const af = result.available_filters || {};
|
||||
@@ -202,24 +342,22 @@ async function executePrimaryQuery() {
|
||||
reasons: af.reasons || [],
|
||||
};
|
||||
|
||||
// Reset supplementary + interactive
|
||||
supplementaryFilters.packages = [];
|
||||
supplementaryFilters.workcenterGroups = [];
|
||||
supplementaryFilters.reason = '';
|
||||
page.value = 1;
|
||||
selectedTrendDates.value = [];
|
||||
selectedParetoValues.value = [];
|
||||
paretoDisplayScope.value = 'all';
|
||||
paretoDimension.value = 'reason';
|
||||
dimensionParetoItems.value = [];
|
||||
resetParetoSelections();
|
||||
resetParetoData();
|
||||
|
||||
// Apply initial data
|
||||
analyticsRawItems.value = Array.isArray(result.analytics_raw)
|
||||
? result.analytics_raw
|
||||
: [];
|
||||
summary.value = result.summary || summary.value;
|
||||
detail.value = result.detail || detail.value;
|
||||
|
||||
await fetchBatchPareto();
|
||||
|
||||
lastQueryAt.value = new Date().toLocaleString('zh-TW');
|
||||
updateUrlState();
|
||||
} catch (error) {
|
||||
@@ -249,8 +387,7 @@ async function refreshView() {
|
||||
supplementaryFilters,
|
||||
metricFilter: metricFilterParam(),
|
||||
trendDates: selectedTrendDates.value,
|
||||
paretoDimension: paretoDimension.value,
|
||||
paretoValues: selectedParetoValues.value,
|
||||
paretoSelections,
|
||||
page: page.value,
|
||||
perPage: DEFAULT_PER_PAGE,
|
||||
policyFilters: {
|
||||
@@ -266,7 +403,6 @@ async function refreshView() {
|
||||
});
|
||||
if (isStaleRequest(requestId)) return;
|
||||
|
||||
// Handle cache expired → auto re-execute primary query
|
||||
if (resp?.success === false && resp?.error === 'cache_expired') {
|
||||
await executePrimaryQuery();
|
||||
return;
|
||||
@@ -309,6 +445,8 @@ function clearFilters() {
|
||||
draftFilters.excludeMaterialScrap = true;
|
||||
draftFilters.excludePbDiode = true;
|
||||
draftFilters.paretoTop80 = true;
|
||||
paretoDisplayScope.value = 'all';
|
||||
resetParetoSelections();
|
||||
void executePrimaryQuery();
|
||||
}
|
||||
|
||||
@@ -329,110 +467,54 @@ function onTrendDateClick(dateStr) {
|
||||
selectedTrendDates.value = [...selectedTrendDates.value, dateStr];
|
||||
}
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
updateUrlState();
|
||||
void Promise.all([refreshView(), fetchBatchPareto()]);
|
||||
}
|
||||
|
||||
function onTrendLegendChange(selected) {
|
||||
trendLegendSelected.value = { ...selected };
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
void Promise.all([refreshView(), fetchBatchPareto()]);
|
||||
}
|
||||
|
||||
function onParetoItemToggle(itemValue) {
|
||||
function onParetoItemToggle(dimension, itemValue) {
|
||||
if (!Object.hasOwn(PARETO_SELECTION_PARAM_MAP, dimension)) {
|
||||
return;
|
||||
}
|
||||
const normalized = String(itemValue || '').trim();
|
||||
if (!normalized) return;
|
||||
if (selectedParetoValues.value.includes(normalized)) {
|
||||
selectedParetoValues.value = selectedParetoValues.value.filter(
|
||||
(item) => item !== normalized,
|
||||
);
|
||||
|
||||
const current = paretoSelections[dimension] || [];
|
||||
if (current.includes(normalized)) {
|
||||
paretoSelections[dimension] = current.filter((item) => item !== normalized);
|
||||
} else {
|
||||
selectedParetoValues.value = [...selectedParetoValues.value, normalized];
|
||||
paretoSelections[dimension] = [...current, normalized];
|
||||
}
|
||||
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void refreshView();
|
||||
void Promise.all([fetchBatchPareto(), refreshView()]);
|
||||
}
|
||||
|
||||
function handleParetoScopeToggle(checked) {
|
||||
draftFilters.paretoTop80 = Boolean(checked);
|
||||
committedPrimary.paretoTop80 = Boolean(checked);
|
||||
updateUrlState();
|
||||
refreshDimensionParetoIfActive();
|
||||
}
|
||||
|
||||
let activeDimRequestId = 0;
|
||||
|
||||
async function fetchDimensionPareto(dim) {
|
||||
if (dim === 'reason' || !queryId.value) return;
|
||||
activeDimRequestId += 1;
|
||||
const myId = activeDimRequestId;
|
||||
dimensionParetoLoading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
query_id: queryId.value,
|
||||
start_date: committedPrimary.startDate,
|
||||
end_date: committedPrimary.endDate,
|
||||
dimension: dim,
|
||||
metric_mode: paretoMetricMode.value === 'defect' ? 'defect' : 'reject_total',
|
||||
pareto_scope: committedPrimary.paretoTop80 ? 'top80' : 'all',
|
||||
include_excluded_scrap: committedPrimary.includeExcludedScrap,
|
||||
exclude_material_scrap: committedPrimary.excludeMaterialScrap,
|
||||
exclude_pb_diode: committedPrimary.excludePbDiode,
|
||||
packages: supplementaryFilters.packages.length > 0 ? supplementaryFilters.packages : undefined,
|
||||
workcenter_groups: supplementaryFilters.workcenterGroups.length > 0 ? supplementaryFilters.workcenterGroups : undefined,
|
||||
reason: supplementaryFilters.reason || undefined,
|
||||
trend_dates: selectedTrendDates.value.length > 0 ? selectedTrendDates.value : undefined,
|
||||
};
|
||||
const resp = await apiGet('/api/reject-history/reason-pareto', { params, timeout: API_TIMEOUT });
|
||||
if (myId !== activeDimRequestId) return;
|
||||
const result = unwrapApiResult(resp, '查詢維度 Pareto 失敗');
|
||||
dimensionParetoItems.value = result.data?.items || [];
|
||||
} catch (err) {
|
||||
if (myId !== activeDimRequestId) return;
|
||||
dimensionParetoItems.value = [];
|
||||
if (err?.name !== 'AbortError') {
|
||||
errorMessage.value = err.message || '查詢維度 Pareto 失敗';
|
||||
}
|
||||
} finally {
|
||||
if (myId === activeDimRequestId) {
|
||||
dimensionParetoLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshDimensionParetoIfActive() {
|
||||
if (paretoDimension.value !== 'reason') {
|
||||
void fetchDimensionPareto(paretoDimension.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onDimensionChange(dim) {
|
||||
paretoDimension.value = dim;
|
||||
selectedParetoValues.value = [];
|
||||
paretoDisplayScope.value = 'all';
|
||||
page.value = 1;
|
||||
if (dim === 'reason') {
|
||||
dimensionParetoItems.value = [];
|
||||
void refreshView();
|
||||
} else {
|
||||
void fetchDimensionPareto(dim);
|
||||
void refreshView();
|
||||
}
|
||||
void fetchBatchPareto();
|
||||
}
|
||||
|
||||
function onParetoDisplayScopeChange(scope) {
|
||||
paretoDisplayScope.value = scope === 'top20' ? 'top20' : 'all';
|
||||
updateUrlState();
|
||||
void fetchBatchPareto();
|
||||
}
|
||||
|
||||
function clearParetoSelection() {
|
||||
selectedParetoValues.value = [];
|
||||
resetParetoSelections();
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void refreshView();
|
||||
void Promise.all([fetchBatchPareto(), refreshView()]);
|
||||
}
|
||||
|
||||
function onSupplementaryChange(filters) {
|
||||
@@ -441,37 +523,32 @@ function onSupplementaryChange(filters) {
|
||||
supplementaryFilters.reason = filters.reason || '';
|
||||
page.value = 1;
|
||||
selectedTrendDates.value = [];
|
||||
selectedParetoValues.value = [];
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
resetParetoSelections();
|
||||
updateUrlState();
|
||||
void Promise.all([refreshView(), fetchBatchPareto()]);
|
||||
}
|
||||
|
||||
function removeFilterChip(chip) {
|
||||
if (!chip?.removable) return;
|
||||
|
||||
if (chip.type === 'pareto-value') {
|
||||
selectedParetoValues.value = selectedParetoValues.value.filter(
|
||||
(value) => value !== chip.value,
|
||||
);
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void refreshView();
|
||||
onParetoItemToggle(chip.dimension, chip.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (chip.type === 'trend-dates') {
|
||||
selectedTrendDates.value = [];
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
updateUrlState();
|
||||
void Promise.all([refreshView(), fetchBatchPareto()]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (chip.type === 'reason') {
|
||||
supplementaryFilters.reason = '';
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
updateUrlState();
|
||||
void Promise.all([refreshView(), fetchBatchPareto()]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -480,8 +557,8 @@ function removeFilterChip(chip) {
|
||||
(g) => g !== chip.value,
|
||||
);
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
updateUrlState();
|
||||
void Promise.all([refreshView(), fetchBatchPareto()]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -490,9 +567,8 @@ function removeFilterChip(chip) {
|
||||
(p) => p !== chip.value,
|
||||
);
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
return;
|
||||
updateUrlState();
|
||||
void Promise.all([refreshView(), fetchBatchPareto()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,10 +587,12 @@ async function exportCsv() {
|
||||
if (supplementaryFilters.reason) params.set('reason', supplementaryFilters.reason);
|
||||
params.set('metric_filter', metricFilterParam());
|
||||
for (const date of selectedTrendDates.value) params.append('trend_dates', date);
|
||||
params.set('pareto_dimension', paretoDimension.value);
|
||||
for (const value of selectedParetoValues.value) params.append('pareto_values', value);
|
||||
for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) {
|
||||
for (const value of paretoSelections[dimension] || []) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Policy filters (applied in-memory on cached data)
|
||||
if (committedPrimary.includeExcludedScrap) params.set('include_excluded_scrap', 'true');
|
||||
if (!committedPrimary.excludeMaterialScrap) params.set('exclude_material_scrap', 'false');
|
||||
if (!committedPrimary.excludePbDiode) params.set('exclude_pb_diode', 'false');
|
||||
@@ -612,99 +690,30 @@ const paretoMetricLabel = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const allParetoItems = computed(() => {
|
||||
const raw = analyticsRawItems.value;
|
||||
if (!raw || raw.length === 0) return [];
|
||||
const selectedParetoCount = computed(() => {
|
||||
let count = 0;
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
count += (paretoSelections[dimension] || []).length;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
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 selectedParetoSummary = computed(() => {
|
||||
const tokens = [];
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
for (const value of paretoSelections[dimension] || []) {
|
||||
tokens.push(`${getDimensionLabel(dimension)}:${value}`);
|
||||
}
|
||||
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 (!committedPrimary.paretoTop80 || items.length === 0) {
|
||||
return items;
|
||||
if (tokens.length <= 3) {
|
||||
return tokens.join(', ');
|
||||
}
|
||||
const cutIdx = items.findIndex((item) => Number(item.cumPct || 0) >= 80);
|
||||
const top80Count = cutIdx >= 0 ? cutIdx + 1 : items.length;
|
||||
return items.slice(0, Math.max(top80Count, Math.min(5, items.length)));
|
||||
return `${tokens.slice(0, 3).join(', ')}... (${tokens.length} 項)`;
|
||||
});
|
||||
|
||||
const activeParetoItems = computed(() => {
|
||||
const baseItems =
|
||||
paretoDimension.value === 'reason'
|
||||
? filteredParetoItems.value
|
||||
: (dimensionParetoItems.value || []);
|
||||
|
||||
if (
|
||||
PARETO_TOP20_DIMENSIONS.has(paretoDimension.value)
|
||||
&& paretoDisplayScope.value === 'top20'
|
||||
) {
|
||||
return baseItems.slice(0, 20);
|
||||
}
|
||||
return baseItems;
|
||||
});
|
||||
|
||||
const selectedParetoDimensionLabel = computed(
|
||||
() => PARETO_DIMENSION_LABELS[paretoDimension.value] || 'Pareto',
|
||||
);
|
||||
|
||||
const activeFilterChips = computed(() => {
|
||||
const chips = [];
|
||||
|
||||
// Primary query info
|
||||
if (committedPrimary.mode === 'date_range') {
|
||||
chips.push({
|
||||
key: 'date-range',
|
||||
@@ -727,7 +736,6 @@ const activeFilterChips = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
// Policy chips
|
||||
chips.push({
|
||||
key: 'policy-mode',
|
||||
label: committedPrimary.includeExcludedScrap
|
||||
@@ -752,7 +760,6 @@ const activeFilterChips = computed(() => {
|
||||
value: '',
|
||||
});
|
||||
|
||||
// Supplementary chips (removable)
|
||||
if (supplementaryFilters.reason) {
|
||||
chips.push({
|
||||
key: `reason:${supplementaryFilters.reason}`,
|
||||
@@ -783,7 +790,6 @@ const activeFilterChips = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Interactive chips (removable)
|
||||
if (selectedTrendDates.value.length > 0) {
|
||||
const dates = selectedTrendDates.value;
|
||||
const label =
|
||||
@@ -797,15 +803,18 @@ const activeFilterChips = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
selectedParetoValues.value.forEach((value) => {
|
||||
chips.push({
|
||||
key: `pareto-value:${paretoDimension.value}:${value}`,
|
||||
label: `${selectedParetoDimensionLabel.value}: ${value}`,
|
||||
removable: true,
|
||||
type: 'pareto-value',
|
||||
value,
|
||||
});
|
||||
});
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
for (const value of paretoSelections[dimension] || []) {
|
||||
chips.push({
|
||||
key: `pareto-value:${dimension}:${value}`,
|
||||
label: `${getDimensionLabel(dimension)}: ${value}`,
|
||||
removable: true,
|
||||
type: 'pareto-value',
|
||||
dimension,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return chips;
|
||||
});
|
||||
@@ -855,18 +864,20 @@ function updateUrlState() {
|
||||
params.set('exclude_material_scrap', String(committedPrimary.excludeMaterialScrap));
|
||||
params.set('exclude_pb_diode', String(committedPrimary.excludePbDiode));
|
||||
|
||||
// Supplementary
|
||||
appendArrayParams(params, 'packages', supplementaryFilters.packages);
|
||||
appendArrayParams(params, 'workcenter_groups', supplementaryFilters.workcenterGroups);
|
||||
if (supplementaryFilters.reason) {
|
||||
params.set('reason', supplementaryFilters.reason);
|
||||
}
|
||||
|
||||
// Interactive
|
||||
appendArrayParams(params, 'trend_dates', selectedTrendDates.value);
|
||||
params.set('pareto_dimension', paretoDimension.value);
|
||||
appendArrayParams(params, 'pareto_values', selectedParetoValues.value);
|
||||
if (paretoDisplayScope.value !== 'all') params.set('pareto_display_scope', paretoDisplayScope.value);
|
||||
for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) {
|
||||
appendArrayParams(params, key, paretoSelections[dimension] || []);
|
||||
}
|
||||
|
||||
if (paretoDisplayScope.value !== 'all') {
|
||||
params.set('pareto_display_scope', paretoDisplayScope.value);
|
||||
}
|
||||
if (!committedPrimary.paretoTop80) {
|
||||
params.set('pareto_scope_all', 'true');
|
||||
}
|
||||
@@ -903,7 +914,6 @@ function readBooleanParam(params, key, defaultValue = false) {
|
||||
function restoreFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// Mode
|
||||
const mode = String(params.get('mode') || '').trim();
|
||||
if (mode === 'container') {
|
||||
queryMode.value = 'container';
|
||||
@@ -920,7 +930,6 @@ function restoreFromUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
// Policy
|
||||
draftFilters.includeExcludedScrap = readBooleanParam(
|
||||
params,
|
||||
'include_excluded_scrap',
|
||||
@@ -934,38 +943,38 @@ function restoreFromUrl() {
|
||||
draftFilters.excludePbDiode = readBooleanParam(params, 'exclude_pb_diode', true);
|
||||
draftFilters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false);
|
||||
|
||||
// Supplementary (will be applied after primary query)
|
||||
const urlPackages = readArrayParam(params, 'packages');
|
||||
const urlWcGroups = readArrayParam(params, 'workcenter_groups');
|
||||
const urlReason = String(params.get('reason') || '').trim();
|
||||
supplementaryFilters.packages = readArrayParam(params, 'packages');
|
||||
supplementaryFilters.workcenterGroups = readArrayParam(params, 'workcenter_groups');
|
||||
supplementaryFilters.reason = String(params.get('reason') || '').trim();
|
||||
|
||||
selectedTrendDates.value = readArrayParam(params, 'trend_dates');
|
||||
|
||||
const restoredSelections = createEmptyParetoSelections();
|
||||
for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) {
|
||||
restoredSelections[dimension] = readArrayParam(params, key);
|
||||
}
|
||||
|
||||
const legacyDimension = String(params.get('pareto_dimension') || '').trim().toLowerCase();
|
||||
const legacyValues = readArrayParam(params, 'pareto_values');
|
||||
const hasSelParams = Object.values(restoredSelections).some((values) => values.length > 0);
|
||||
if (!hasSelParams && legacyValues.length > 0) {
|
||||
const fallbackDimension = Object.hasOwn(PARETO_SELECTION_PARAM_MAP, legacyDimension)
|
||||
? legacyDimension
|
||||
: 'reason';
|
||||
restoredSelections[fallbackDimension] = legacyValues;
|
||||
}
|
||||
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
paretoSelections[dimension] = restoredSelections[dimension];
|
||||
}
|
||||
|
||||
// Interactive
|
||||
const urlTrendDates = readArrayParam(params, 'trend_dates');
|
||||
const rawParetoDimension = String(params.get('pareto_dimension') || '').trim().toLowerCase();
|
||||
const urlParetoDimension = Object.hasOwn(PARETO_DIMENSION_LABELS, rawParetoDimension)
|
||||
? rawParetoDimension
|
||||
: 'reason';
|
||||
const urlParetoValues = readArrayParam(params, 'pareto_values');
|
||||
const urlParetoDisplayScope = String(params.get('pareto_display_scope') || '').trim().toLowerCase();
|
||||
const parsedPage = Number(params.get('page') || '1');
|
||||
|
||||
paretoDimension.value = urlParetoDimension;
|
||||
selectedParetoValues.value = urlParetoValues;
|
||||
paretoDisplayScope.value = urlParetoDisplayScope === 'top20' ? 'top20' : 'all';
|
||||
|
||||
return {
|
||||
packages: urlPackages,
|
||||
workcenterGroups: urlWcGroups,
|
||||
reason: urlReason,
|
||||
trendDates: urlTrendDates,
|
||||
paretoDimension: urlParetoDimension,
|
||||
paretoValues: urlParetoValues,
|
||||
paretoDisplayScope: paretoDisplayScope.value,
|
||||
page: Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1,
|
||||
};
|
||||
const parsedPage = Number(params.get('page') || '1');
|
||||
page.value = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
|
||||
}
|
||||
|
||||
// ---- Mount ----
|
||||
onMounted(() => {
|
||||
setDefaultDateRange();
|
||||
restoreFromUrl();
|
||||
@@ -1004,11 +1013,13 @@ onMounted(() => {
|
||||
:resolution-info="resolutionInfo"
|
||||
:loading="loading"
|
||||
:active-filter-chips="activeFilterChips"
|
||||
:pareto-display-scope="paretoDisplayScope"
|
||||
@apply="applyFilters"
|
||||
@clear="clearFilters"
|
||||
@export-csv="exportCsv"
|
||||
@remove-chip="removeFilterChip"
|
||||
@pareto-scope-toggle="handleParetoScopeToggle"
|
||||
@pareto-display-scope-change="onParetoDisplayScopeChange"
|
||||
@update:query-mode="queryMode = $event"
|
||||
@update:container-input-type="containerInputType = $event"
|
||||
@update:container-input="containerInput = $event"
|
||||
@@ -1026,26 +1037,22 @@ onMounted(() => {
|
||||
@legend-change="onTrendLegendChange"
|
||||
/>
|
||||
|
||||
<ParetoSection
|
||||
:items="activeParetoItems"
|
||||
:selected-values="selectedParetoValues"
|
||||
<ParetoGrid
|
||||
:pareto-data="paretoData"
|
||||
:pareto-selections="paretoSelections"
|
||||
:display-scope="paretoDisplayScope"
|
||||
:selected-dates="selectedTrendDates"
|
||||
:metric-label="paretoMetricLabel"
|
||||
:loading="loading.querying || dimensionParetoLoading"
|
||||
:dimension="paretoDimension"
|
||||
:show-dimension-selector="committedPrimary.mode === 'date_range'"
|
||||
:loading="loading.querying || loading.pareto"
|
||||
@item-toggle="onParetoItemToggle"
|
||||
@dimension-change="onDimensionChange"
|
||||
@display-scope-change="onParetoDisplayScopeChange"
|
||||
/>
|
||||
|
||||
<DetailTable
|
||||
:items="detail.items"
|
||||
:pagination="pagination"
|
||||
:loading="loading.list"
|
||||
:selected-pareto-values="selectedParetoValues"
|
||||
:selected-pareto-dimension-label="selectedParetoDimensionLabel"
|
||||
:selected-pareto-count="selectedParetoCount"
|
||||
:selected-pareto-summary="selectedParetoSummary"
|
||||
@go-to-page="goToPage"
|
||||
@clear-pareto-selection="clearParetoSelection"
|
||||
/>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
items: { type: Array, default: () => [] },
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }),
|
||||
},
|
||||
loading: { type: Boolean, default: false },
|
||||
selectedParetoValues: { type: Array, default: () => [] },
|
||||
selectedParetoDimensionLabel: { type: String, default: '' },
|
||||
selectedParetoCount: { type: Number, default: 0 },
|
||||
selectedParetoSummary: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineEmits(['go-to-page', 'clear-pareto-selection']);
|
||||
@@ -26,13 +26,8 @@ function formatNumber(value) {
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
明細列表
|
||||
<span v-if="selectedParetoValues.length > 0" class="detail-reason-badge">
|
||||
{{ selectedParetoDimensionLabel || 'Pareto 篩選' }}:
|
||||
{{
|
||||
selectedParetoValues.length === 1
|
||||
? selectedParetoValues[0]
|
||||
: `${selectedParetoValues.length} 項`
|
||||
}}
|
||||
<span v-if="selectedParetoCount > 0" class="detail-reason-badge">
|
||||
Pareto 篩選: {{ selectedParetoSummary || `${selectedParetoCount} 項` }}
|
||||
<button type="button" class="badge-clear" @click="$emit('clear-pareto-selection')">×</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -8,21 +8,23 @@ const props = defineProps({
|
||||
containerInput: { type: String, default: '' },
|
||||
availableFilters: { type: Object, default: () => ({}) },
|
||||
supplementaryFilters: { type: Object, default: () => ({}) },
|
||||
queryId: { type: String, default: '' },
|
||||
resolutionInfo: { type: Object, default: null },
|
||||
loading: { type: Object, required: true },
|
||||
activeFilterChips: { type: Array, default: () => [] },
|
||||
});
|
||||
queryId: { type: String, default: '' },
|
||||
resolutionInfo: { type: Object, default: null },
|
||||
loading: { type: Object, required: true },
|
||||
activeFilterChips: { type: Array, default: () => [] },
|
||||
paretoDisplayScope: { type: String, default: 'all' },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'apply',
|
||||
'clear',
|
||||
'export-csv',
|
||||
'remove-chip',
|
||||
'pareto-scope-toggle',
|
||||
'update:queryMode',
|
||||
'update:containerInputType',
|
||||
'update:containerInput',
|
||||
'remove-chip',
|
||||
'pareto-scope-toggle',
|
||||
'pareto-display-scope-change',
|
||||
'update:queryMode',
|
||||
'update:containerInputType',
|
||||
'update:containerInput',
|
||||
'supplementary-change',
|
||||
]);
|
||||
|
||||
@@ -191,6 +193,15 @@ function emitSupplementary(patch) {
|
||||
/>
|
||||
Pareto 僅顯示累計前 80%
|
||||
</label>
|
||||
<label class="filter-label">顯示範圍</label>
|
||||
<select
|
||||
class="dimension-select pareto-scope-select"
|
||||
:value="paretoDisplayScope"
|
||||
@change="$emit('pareto-display-scope-change', $event.target.value)"
|
||||
>
|
||||
<option value="all">全部顯示</option>
|
||||
<option value="top20">只顯示 TOP 20</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="supplementary-row">
|
||||
<div class="filter-group">
|
||||
|
||||
45
frontend/src/reject-history/components/ParetoGrid.vue
Normal file
45
frontend/src/reject-history/components/ParetoGrid.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import ParetoSection from './ParetoSection.vue';
|
||||
|
||||
const DIMENSIONS = [
|
||||
{ key: 'reason', label: '不良原因' },
|
||||
{ key: 'package', label: 'PACKAGE' },
|
||||
{ key: 'type', label: 'TYPE' },
|
||||
{ key: 'workflow', label: 'WORKFLOW' },
|
||||
{ key: 'workcenter', label: '站點' },
|
||||
{ key: 'equipment', label: '機台' },
|
||||
];
|
||||
|
||||
const props = defineProps({
|
||||
paretoData: { type: Object, default: () => ({}) },
|
||||
paretoSelections: { type: Object, default: () => ({}) },
|
||||
loading: { type: Boolean, default: false },
|
||||
metricLabel: { type: String, default: '報廢量' },
|
||||
selectedDates: { type: Array, default: () => [] },
|
||||
displayScope: { type: String, default: 'all' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['item-toggle']);
|
||||
|
||||
function onItemToggle(dimension, value) {
|
||||
emit('item-toggle', dimension, value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pareto-grid">
|
||||
<ParetoSection
|
||||
v-for="dim in DIMENSIONS"
|
||||
:key="dim.key"
|
||||
:dimension="dim.key"
|
||||
:dimension-label="dim.label"
|
||||
:items="paretoData[dim.key]?.items || []"
|
||||
:selected-values="paretoSelections[dim.key] || []"
|
||||
:selected-dates="selectedDates"
|
||||
:metric-label="metricLabel"
|
||||
:display-scope="displayScope"
|
||||
:loading="loading"
|
||||
@item-toggle="onItemToggle(dim.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,22 +1,14 @@
|
||||
<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 DIMENSION_OPTIONS = [
|
||||
{ value: 'reason', label: '不良原因' },
|
||||
{ value: 'package', label: 'PACKAGE' },
|
||||
{ value: 'type', label: 'TYPE' },
|
||||
{ value: 'workflow', label: 'WORKFLOW' },
|
||||
{ value: 'workcenter', label: '站點' },
|
||||
{ value: 'equipment', label: '機台' },
|
||||
];
|
||||
|
||||
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 DISPLAY_SCOPE_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']);
|
||||
|
||||
const props = defineProps({
|
||||
@@ -26,90 +18,101 @@ const props = defineProps({
|
||||
metricLabel: { type: String, default: '報廢量' },
|
||||
loading: { type: Boolean, default: false },
|
||||
dimension: { type: String, default: 'reason' },
|
||||
showDimensionSelector: { type: Boolean, default: false },
|
||||
dimensionLabel: { type: String, default: 'Pareto' },
|
||||
displayScope: { type: String, default: 'all' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['item-toggle', 'dimension-change', 'display-scope-change']);
|
||||
const emit = defineEmits(['item-toggle']);
|
||||
|
||||
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
|
||||
const selectedValueSet = computed(() => new Set((props.selectedValues || []).map((item) => String(item || '').trim())));
|
||||
const showDisplayScopeSelector = computed(
|
||||
() => DISPLAY_SCOPE_TOP20_DIMENSIONS.has(props.dimension),
|
||||
);
|
||||
const selectedValueSet = computed(
|
||||
() => new Set((props.selectedValues || []).map((item) => String(item || '').trim()),
|
||||
));
|
||||
|
||||
const dimensionLabel = computed(() => {
|
||||
const opt = DIMENSION_OPTIONS.find((o) => o.value === props.dimension);
|
||||
return opt ? opt.label : '報廢原因';
|
||||
const displayItems = computed(() => {
|
||||
const items = Array.isArray(props.items) ? props.items : [];
|
||||
if (
|
||||
props.displayScope === 'top20'
|
||||
&& DISPLAY_SCOPE_TOP20_DIMENSIONS.has(props.dimension)
|
||||
) {
|
||||
return items.slice(0, 20);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const hasData = computed(() => displayItems.value.length > 0);
|
||||
|
||||
const showTop20Badge = computed(
|
||||
() => props.displayScope === 'top20' && DISPLAY_SCOPE_TOP20_DIMENSIONS.has(props.dimension),
|
||||
);
|
||||
|
||||
function isSelected(value) {
|
||||
return selectedValueSet.value.has(String(value || '').trim());
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function formatPct(value) {
|
||||
return `${Number(value || 0).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const items = displayItems.value;
|
||||
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: items.length > 10 ? 110 : 96,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: items.map((item) => item.reason || '(未填寫)'),
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: items.length > 10 ? 55 : items.length > 5 ? 35 : 0,
|
||||
fontSize: items.length > 15 ? 9 : items.length > 8 ? 10 : 11,
|
||||
overflow: 'truncate',
|
||||
width: items.length > 10 ? 60 : 80,
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
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: {
|
||||
@@ -120,24 +123,24 @@ const chartOption = computed(() => {
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
{
|
||||
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 itemValue = props.items?.[params.dataIndex]?.reason;
|
||||
const itemValue = displayItems.value?.[params.dataIndex]?.reason;
|
||||
if (itemValue) {
|
||||
emit('item-toggle', itemValue);
|
||||
}
|
||||
@@ -145,50 +148,32 @@ function handleChartClick(params) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<div class="card-header pareto-header">
|
||||
<div class="card-title">
|
||||
{{ metricLabel }} vs {{ dimensionLabel }}(Pareto)
|
||||
<span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span>
|
||||
</div>
|
||||
<div class="pareto-controls">
|
||||
<select
|
||||
v-if="showDimensionSelector"
|
||||
class="dimension-select"
|
||||
:value="dimension"
|
||||
@change="emit('dimension-change', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in DIMENSION_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="showDisplayScopeSelector"
|
||||
class="dimension-select pareto-scope-select"
|
||||
:value="displayScope"
|
||||
@change="emit('display-scope-change', $event.target.value)"
|
||||
>
|
||||
<option value="all">全部顯示</option>
|
||||
<option value="top20">只顯示 TOP 20</option>
|
||||
</select>
|
||||
<section class="card">
|
||||
<div class="card-header pareto-header">
|
||||
<div class="card-title">
|
||||
{{ metricLabel }} vs {{ dimensionLabel }}(Pareto)
|
||||
<span v-if="showTop20Badge" class="pareto-date-badge">TOP 20</span>
|
||||
<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>{{ dimensionLabel }}</th>
|
||||
<th>{{ metricLabel }}</th>
|
||||
<th>占比</th>
|
||||
<th>累積</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>{{ dimensionLabel }}</th>
|
||||
<th>{{ metricLabel }}</th>
|
||||
<th>占比</th>
|
||||
<th>累積</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
v-for="item in displayItems"
|
||||
:key="item.reason"
|
||||
:class="{ active: isSelected(item.reason) }"
|
||||
>
|
||||
@@ -199,14 +184,14 @@ function handleChartClick(params) {
|
||||
</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>
|
||||
<td>{{ formatPct(item.cumPct) }}</td>
|
||||
</tr>
|
||||
<tr v-if="displayItems.length === 0">
|
||||
<td colspan="4" class="placeholder">No data</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
}
|
||||
|
||||
.supplementary-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -304,11 +308,18 @@
|
||||
.chart-wrap,
|
||||
.pareto-chart-wrap {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
height: 260px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pareto-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pareto-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -350,8 +361,8 @@
|
||||
|
||||
.pareto-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pareto-table-wrap {
|
||||
@@ -618,7 +629,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
@media (max-width: 1200px) {
|
||||
.filter-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -631,12 +642,12 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pareto-layout {
|
||||
grid-template-columns: 1fr;
|
||||
.pareto-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@media (max-width: 768px) {
|
||||
.reject-summary-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -662,4 +673,8 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pareto-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildRejectOptionsRequestParams,
|
||||
buildViewParams,
|
||||
pruneRejectFilterSelections,
|
||||
} from '../src/core/reject-history-filters.js';
|
||||
import {
|
||||
@@ -62,6 +63,45 @@ test('reject-history prune removes invalid selected values', () => {
|
||||
assert.equal(pruned.removedCount, 3);
|
||||
});
|
||||
|
||||
test('reject-history view params include multi-dimension pareto selections', () => {
|
||||
const params = buildViewParams('qid-001', {
|
||||
supplementaryFilters: {
|
||||
packages: ['PKG-A'],
|
||||
workcenterGroups: ['WB'],
|
||||
reason: '001_A',
|
||||
},
|
||||
trendDates: ['2026-02-01'],
|
||||
paretoSelections: {
|
||||
reason: ['001_A'],
|
||||
type: ['TYPE-A', 'TYPE-B'],
|
||||
workflow: ['WF-01'],
|
||||
},
|
||||
page: 2,
|
||||
perPage: 80,
|
||||
policyFilters: {
|
||||
includeExcludedScrap: true,
|
||||
excludeMaterialScrap: false,
|
||||
excludePbDiode: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(params, {
|
||||
query_id: 'qid-001',
|
||||
packages: ['PKG-A'],
|
||||
workcenter_groups: ['WB'],
|
||||
reason: '001_A',
|
||||
trend_dates: ['2026-02-01'],
|
||||
sel_reason: ['001_A'],
|
||||
sel_type: ['TYPE-A', 'TYPE-B'],
|
||||
sel_workflow: ['WF-01'],
|
||||
page: 2,
|
||||
per_page: 80,
|
||||
include_excluded_scrap: 'true',
|
||||
exclude_material_scrap: 'false',
|
||||
exclude_pb_diode: 'false',
|
||||
});
|
||||
});
|
||||
|
||||
test('resource-history derives families from upstream group and flags', () => {
|
||||
const resources = [
|
||||
{ id: 'R1', name: 'MC-01', family: 'FAM-A', workcenterGroup: 'WB', isProduction: true, isKey: true, isMonitor: false },
|
||||
|
||||
Reference in New Issue
Block a user