Files
DashBoard/frontend/src/reject-history/App.vue

969 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>