feat(reject-history): ship report page and archive openspec change

This commit is contained in:
egg
2026-02-13 20:35:52 +08:00
parent 7cbb155619
commit 05d907ac72
47 changed files with 4419 additions and 73 deletions

View File

@@ -34,6 +34,10 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
() => import('../hold-history/App.vue'),
[() => import('../wip-shared/styles.css'), () => import('../hold-history/style.css')],
),
'/reject-history': createNativeLoader(
() => import('../reject-history/App.vue'),
[() => import('../wip-shared/styles.css'), () => import('../reject-history/style.css')],
),
'/resource': createNativeLoader(
() => import('../resource-status/App.vue'),
[() => import('../resource-shared/styles.css'), () => import('../resource-status/style.css')],

View File

@@ -4,6 +4,7 @@ const IN_SCOPE_REPORT_ROUTES = Object.freeze([
'/hold-overview',
'/hold-detail',
'/hold-history',
'/reject-history',
'/resource',
'/resource-history',
'/qc-gate',
@@ -109,6 +110,17 @@ const ROUTE_CONTRACTS = Object.freeze({
scope: 'in-scope',
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
}),
'/reject-history': buildContract({
route: '/reject-history',
routeId: 'reject-history',
renderMode: 'native',
owner: 'frontend-mes-reporting',
title: '報廢歷史查詢',
rollbackStrategy: 'fallback_to_legacy_route',
visibilityPolicy: 'released_or_admin',
scope: 'in-scope',
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
}),
'/resource': buildContract({
route: '/resource',
routeId: 'resource',

View File

@@ -0,0 +1,968 @@
<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]);
const API_TIMEOUT = 60000;
const DEFAULT_PER_PAGE = 50;
const filters = reactive({
startDate: '',
endDate: '',
workcenterGroups: [],
packages: [],
reason: '',
includeExcludedScrap: false,
excludeMaterialScrap: true,
paretoTop80: true,
});
const page = ref(1);
const detailReason = ref('');
const options = reactive({
workcenterGroups: [],
packages: [],
reasons: [],
});
const summary = ref({
MOVEIN_QTY: 0,
REJECT_TOTAL_QTY: 0,
DEFECT_QTY: 0,
REJECT_RATE_PCT: 0,
DEFECT_RATE_PCT: 0,
REJECT_SHARE_PCT: 0,
AFFECTED_LOT_COUNT: 0,
AFFECTED_WORKORDER_COUNT: 0,
});
const trend = ref({ items: [], granularity: 'day' });
const pareto = ref({ items: [], metric_mode: 'reject_total', pareto_scope: 'top80' });
const detail = ref({
items: [],
pagination: {
page: 1,
perPage: DEFAULT_PER_PAGE,
total: 0,
totalPages: 1,
},
});
const loading = reactive({
initial: true,
querying: false,
options: false,
list: false,
pareto: false,
});
const errorMessage = ref('');
const lastQueryAt = ref('');
const lastPolicyMeta = ref({
include_excluded_scrap: false,
exclusion_applied: false,
excluded_reason_count: 0,
});
let activeRequestId = 0;
function nextRequestId() {
activeRequestId += 1;
return activeRequestId;
}
function isStaleRequest(requestId) {
return requestId !== activeRequestId;
}
function toDateString(value) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function setDefaultDateRange() {
const today = new Date();
const end = new Date(today);
end.setDate(end.getDate() - 1);
const start = new Date(end);
start.setDate(start.getDate() - 29);
filters.startDate = toDateString(start);
filters.endDate = toDateString(end);
}
function readArrayParam(params, key) {
const repeated = params.getAll(key).map((value) => String(value || '').trim()).filter(Boolean);
if (repeated.length > 0) {
return repeated;
}
return String(params.get(key) || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function readBooleanParam(params, key, defaultValue = false) {
const value = String(params.get(key) || '').trim().toLowerCase();
if (!value) {
return defaultValue;
}
return ['1', 'true', 'yes', 'y', 'on'].includes(value);
}
function restoreFromUrl() {
const params = new URLSearchParams(window.location.search);
const startDate = String(params.get('start_date') || '').trim();
const endDate = String(params.get('end_date') || '').trim();
if (startDate && endDate) {
filters.startDate = startDate;
filters.endDate = endDate;
}
const wcGroups = readArrayParam(params, 'workcenter_groups');
if (wcGroups.length > 0) {
filters.workcenterGroups = wcGroups;
}
const packages = readArrayParam(params, 'packages');
if (packages.length > 0) {
filters.packages = packages;
}
const reason = String(params.get('reason') || '').trim();
if (reason) {
filters.reason = reason;
}
const detailReasonFromUrl = String(params.get('detail_reason') || '').trim();
if (detailReasonFromUrl) {
detailReason.value = detailReasonFromUrl;
}
filters.includeExcludedScrap = readBooleanParam(params, 'include_excluded_scrap', false);
filters.excludeMaterialScrap = readBooleanParam(params, 'exclude_material_scrap', true);
filters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false);
const parsedPage = Number(params.get('page') || '1');
page.value = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
}
function updateUrlState() {
const params = new URLSearchParams();
params.set('start_date', filters.startDate);
params.set('end_date', filters.endDate);
filters.workcenterGroups.forEach((item) => params.append('workcenter_groups', item));
filters.packages.forEach((item) => params.append('packages', item));
if (filters.reason) {
params.set('reason', filters.reason);
}
if (detailReason.value) {
params.set('detail_reason', detailReason.value);
}
if (filters.includeExcludedScrap) {
params.set('include_excluded_scrap', 'true');
}
params.set('exclude_material_scrap', String(filters.excludeMaterialScrap));
if (!filters.paretoTop80) {
params.set('pareto_scope_all', 'true');
}
if (page.value > 1) {
params.set('page', String(page.value));
}
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;
}
if (result?.success === false) {
throw new Error(result.error || fallbackMessage);
}
return result;
}
function buildCommonParams({ reason = filters.reason } = {}) {
const params = {
start_date: filters.startDate,
end_date: filters.endDate,
workcenter_groups: filters.workcenterGroups,
packages: filters.packages,
include_excluded_scrap: filters.includeExcludedScrap,
exclude_material_scrap: filters.excludeMaterialScrap,
};
if (reason) {
params.reasons = [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 {
...buildCommonParams({ reason: effectiveReason }),
page: page.value,
per_page: DEFAULT_PER_PAGE,
};
}
async function fetchOptions() {
const response = await apiGet('/api/reject-history/options', {
params: {
start_date: filters.startDate,
end_date: filters.endDate,
include_excluded_scrap: filters.includeExcludedScrap,
exclude_material_scrap: filters.excludeMaterialScrap,
},
timeout: API_TIMEOUT,
});
const payload = unwrapApiResult(response, '載入篩選選項失敗');
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', {
params: {
...buildCommonParams(),
granularity: 'day',
},
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, '載入柏拉圖資料失敗');
return payload;
}
async function fetchList() {
const response = await apiGet('/api/reject-history/list', {
params: buildListParams(),
timeout: API_TIMEOUT,
});
const payload = unwrapApiResult(response, '載入明細資料失敗');
return payload;
}
function mergePolicyMeta(meta) {
lastPolicyMeta.value = {
include_excluded_scrap: Boolean(meta?.include_excluded_scrap),
exclusion_applied: Boolean(meta?.exclusion_applied),
excluded_reason_count: Number(meta?.excluded_reason_count || 0),
};
}
function normalizeFiltersByOptions() {
if (filters.reason && !options.reasons.includes(filters.reason)) {
filters.reason = '';
}
if (filters.packages.length > 0) {
const packageSet = new Set(options.packages);
filters.packages = filters.packages.filter((pkg) => packageSet.has(pkg));
}
}
async function loadAllData({ loadOptions = true } = {}) {
const requestId = nextRequestId();
loading.querying = true;
loading.list = true;
loading.pareto = true;
errorMessage.value = '';
try {
const tasks = [fetchSummary(), fetchTrend(), fetchPareto(), fetchList()];
if (loadOptions) {
loading.options = true;
tasks.push(fetchOptions());
}
const responses = await Promise.all(tasks);
if (isStaleRequest(requestId)) {
return;
}
const [summaryResp, trendResp, paretoResp, listResp, optionsResp] = responses;
summary.value = summaryResp.data || summary.value;
trend.value = trendResp.data || trend.value;
pareto.value = paretoResp.data || pareto.value;
detail.value = listResp.data || detail.value;
const meta = {
...(summaryResp.meta || {}),
...(trendResp.meta || {}),
...(paretoResp.meta || {}),
...(listResp.meta || {}),
};
mergePolicyMeta(meta);
if (loadOptions && optionsResp) {
options.workcenterGroups = Array.isArray(optionsResp.workcenter_groups)
? optionsResp.workcenter_groups
: [];
options.reasons = Array.isArray(optionsResp.reasons)
? optionsResp.reasons
: [];
options.packages = Array.isArray(optionsResp.packages)
? optionsResp.packages
: [];
normalizeFiltersByOptions();
}
lastQueryAt.value = new Date().toLocaleString('zh-TW');
updateUrlState();
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
loading.initial = false;
loading.querying = false;
loading.options = false;
loading.list = false;
loading.pareto = false;
}
}
async function loadListOnly() {
const requestId = nextRequestId();
loading.list = true;
errorMessage.value = '';
try {
const listResp = await fetchList();
if (isStaleRequest(requestId)) {
return;
}
detail.value = listResp.data || detail.value;
mergePolicyMeta(listResp.meta || {});
updateUrlState();
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入明細資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
loading.list = false;
}
}
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 = '';
void loadAllData({ loadOptions: true });
}
function clearFilters() {
setDefaultDateRange();
filters.workcenterGroups = [];
filters.packages = [];
filters.reason = '';
detailReason.value = '';
filters.includeExcludedScrap = false;
filters.excludeMaterialScrap = true;
filters.paretoTop80 = true;
page.value = 1;
void loadAllData({ loadOptions: true });
}
function goToPage(nextPage) {
if (nextPage < 1 || nextPage > Number(detail.value?.pagination?.totalPages || 1)) {
return;
}
page.value = nextPage;
void loadListOnly();
}
function onParetoClick(reason) {
if (!reason) {
return;
}
detailReason.value = detailReason.value === reason ? '' : reason;
page.value = 1;
void loadListOnly();
}
function handleParetoScopeToggle(checked) {
filters.paretoTop80 = Boolean(checked);
void loadParetoOnly();
}
function removeFilterChip(chip) {
if (!chip?.removable) {
return;
}
if (chip.type === 'reason') {
filters.reason = '';
detailReason.value = '';
} else if (chip.type === 'workcenter') {
filters.workcenterGroups = filters.workcenterGroups.filter((item) => item !== chip.value);
} else if (chip.type === 'package') {
filters.packages = filters.packages.filter((item) => item !== chip.value);
} else if (chip.type === 'detail-reason') {
detailReason.value = '';
page.value = 1;
void loadListOnly();
return;
} else {
return;
}
page.value = 1;
void loadAllData({ loadOptions: false });
}
function exportCsv() {
const params = new URLSearchParams();
params.set('start_date', filters.startDate);
params.set('end_date', filters.endDate);
params.set('include_excluded_scrap', String(filters.includeExcludedScrap));
params.set('exclude_material_scrap', String(filters.excludeMaterialScrap));
filters.workcenterGroups.forEach((item) => params.append('workcenter_groups', item));
filters.packages.forEach((item) => params.append('packages', item));
const effectiveReason = detailReason.value || filters.reason;
if (effectiveReason) {
params.append('reasons', effectiveReason);
}
window.location.href = `/api/reject-history/export?${params.toString()}`;
}
const totalScrapQty = computed(() => {
return Number(summary.value.REJECT_TOTAL_QTY || 0) + Number(summary.value.DEFECT_QTY || 0);
});
const activeFilterChips = computed(() => {
const chips = [
{
key: 'date-range',
label: `日期: ${filters.startDate || '-'} ~ ${filters.endDate || '-'}`,
removable: false,
type: 'date',
value: '',
},
{
key: 'policy-mode',
label: filters.includeExcludedScrap ? '政策: 納入不計良率報廢' : '政策: 排除不計良率報廢',
removable: false,
type: 'policy',
value: '',
},
{
key: 'material-policy-mode',
label: filters.excludeMaterialScrap ? '原物料: 已排除' : '原物料: 已納入',
removable: false,
type: 'policy',
value: '',
},
];
if (filters.reason) {
chips.push({
key: `reason:${filters.reason}`,
label: `原因: ${filters.reason}`,
removable: true,
type: 'reason',
value: filters.reason,
});
}
if (detailReason.value) {
chips.push({
key: `detail-reason:${detailReason.value}`,
label: `明細原因: ${detailReason.value}`,
removable: true,
type: 'detail-reason',
value: detailReason.value,
});
}
filters.workcenterGroups.forEach((group) => {
chips.push({
key: `workcenter:${group}`,
label: `WC: ${group}`,
removable: true,
type: 'workcenter',
value: group,
});
});
filters.packages.forEach((pkg) => {
chips.push({
key: `package:${pkg}`,
label: `Package: ${pkg}`,
removable: true,
type: 'package',
value: pkg,
});
});
return chips;
});
const kpiCards = computed(() => {
return [
{ key: 'REJECT_TOTAL_QTY', label: '扣帳報廢量', value: summary.value.REJECT_TOTAL_QTY, lane: 'reject', isPct: false },
{ key: 'DEFECT_QTY', label: '不扣帳報廢量', value: summary.value.DEFECT_QTY, lane: 'defect', isPct: false },
{ key: 'TOTAL_SCRAP_QTY', label: '總報廢量', value: totalScrapQty.value, lane: 'neutral', isPct: false },
{ key: 'REJECT_SHARE_PCT', label: '扣帳占比', value: summary.value.REJECT_SHARE_PCT, lane: 'neutral', isPct: true },
{ key: 'AFFECTED_LOT_COUNT', label: '受影響 LOT', value: summary.value.AFFECTED_LOT_COUNT, lane: 'neutral', isPct: false },
{ key: 'AFFECTED_WORKORDER_COUNT', label: '受影響工單', value: summary.value.AFFECTED_WORKORDER_COUNT, lane: 'neutral', isPct: false },
];
});
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,
total: 0,
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();
void loadAllData({ loadOptions: true });
});
</script>
<template>
<div class="dashboard reject-history-page">
<header class="header reject-history-header">
<div class="header-left">
<h1>報廢歷史查詢</h1>
</div>
<div class="header-right">
<div class="last-update" v-if="lastQueryAt">更新時間{{ lastQueryAt }}</div>
</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>
<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 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>
<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>
<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>
<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>
</template>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>報廢歷史查詢</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import '../wip-shared/styles.css';
import './style.css';
createApp(App).mount('#app');

View File

@@ -0,0 +1,418 @@
.reject-history-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 14px;
}
.card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: #f8fafc;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.card-body {
padding: 14px 16px;
}
.error-banner {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 6px;
background: #fef2f2;
color: #991b1b;
font-size: 13px;
}
.filter-panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
align-items: end;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-group-wide {
grid-column: span 2;
}
.filter-label {
font-size: 12px;
font-weight: 700;
color: #475569;
}
.filter-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
background: #fff;
}
.filter-input:focus {
outline: none;
border-color: #0ea5e9;
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
}
.filter-group .multi-select {
width: 100%;
min-width: 0;
}
.inline-toggle-group {
align-self: center;
}
.checkbox-row {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.checkbox-pill {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 10px;
font-size: 13px;
color: #334155;
background: #f8fafc;
}
.checkbox-pill input[type='checkbox'] {
margin: 0;
width: 14px;
height: 14px;
accent-color: #2563eb;
}
.filter-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
grid-column: span 2;
}
.active-filter-chip-row {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid var(--border);
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid #cbd5e1;
background: #f8fafc;
font-size: 12px;
color: #334155;
}
.chip-remove {
border: 0;
background: transparent;
color: #475569;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.btn-export {
background: #0f766e;
color: #fff;
}
.btn-export:hover {
background: #0b5e59;
}
.reject-summary-row {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.lane-reject {
border-top: 3px solid #dc2626;
}
.lane-defect {
border-top: 3px solid #0284c7;
}
.lane-neutral {
border-top: 3px solid #64748b;
}
.chart-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
margin-bottom: 14px;
}
.chart-wrap,
.pareto-chart-wrap {
height: 340px;
position: relative;
}
.pareto-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.pareto-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 12px;
}
.pareto-table-wrap {
overflow: auto;
}
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.detail-table th,
.detail-table td {
border-bottom: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.detail-table thead th {
position: sticky;
top: 0;
background: #f8fafc;
z-index: 1;
}
.reason-link {
border: none;
background: transparent;
color: #1d4ed8;
cursor: pointer;
text-decoration: underline;
font-size: 12px;
}
.pareto-table tbody tr.active {
background: #eff6ff;
}
.detail-table-wrap {
overflow: auto;
}
/* ---- MultiSelect component styles (shared-ui compatible) ---- */
.multi-select {
position: relative;
min-width: 160px;
}
.multi-select-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
color: #1f2937;
background: #ffffff;
cursor: pointer;
}
.multi-select-trigger:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.multi-select-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.multi-select-arrow {
color: #64748b;
font-size: 11px;
}
.multi-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
border: 1px solid var(--border);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
overflow: hidden;
}
.multi-select-search {
display: block;
width: 100%;
border: none;
border-bottom: 1px solid var(--border);
padding: 8px 12px;
font-size: 13px;
color: #1f2937;
outline: none;
background: #f8fafc;
}
.multi-select-search::placeholder {
color: #94a3b8;
}
.multi-select-options {
max-height: 250px;
overflow-y: auto;
padding: 8px 0;
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
font-size: 13px;
color: #334155;
cursor: pointer;
text-align: left;
}
.multi-select-option:hover {
background: #f8fafc;
}
.multi-select-option input[type='checkbox'] {
margin: 0;
width: 14px;
height: 14px;
accent-color: #2563eb;
}
.multi-select-empty {
padding: 12px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
.multi-select-actions {
display: flex;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--border);
background: #f8fafc;
}
.btn-sm {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #f8fafc;
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.btn-sm:hover {
border-color: #c2d0e0;
background: #eef4fb;
}
@media (max-width: 1400px) {
.reject-summary-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1180px) {
.filter-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-group-wide {
grid-column: span 2;
}
.filter-actions {
grid-column: span 2;
justify-content: flex-start;
}
.pareto-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.reject-summary-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-panel {
grid-template-columns: 1fr;
}
.filter-group-wide,
.filter-actions {
grid-column: span 1;
}
.checkbox-row {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -19,6 +19,7 @@ export default defineConfig(({ mode }) => ({
'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'),
'hold-overview': resolve(__dirname, 'src/hold-overview/index.html'),
'hold-history': resolve(__dirname, 'src/hold-history/index.html'),
'reject-history': resolve(__dirname, 'src/reject-history/index.html'),
'resource-status': resolve(__dirname, 'src/resource-status/index.html'),
'resource-history': resolve(__dirname, 'src/resource-history/index.html'),
'job-query': resolve(__dirname, 'src/job-query/main.js'),