feat(reject-history): add detail columns, CSV export polish, and metric filter sync
Add EQUIPMENTNAME and REJECTCOMMENT columns to the detail table, list SQL, and per-LOT base query. Rewrite CSV export to use per-LOT rows with Chinese headers, BOM UTF-8 encoding, and fetch-based blob download with loading spinner. Sync trend chart legend filter (reject/defect) to detail table and export via metric_filter parameter through the full stack. Fix chart sizing with containLabel and autoresize throttle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,7 @@ const loading = reactive({
|
||||
querying: false,
|
||||
options: false,
|
||||
list: false,
|
||||
exporting: false,
|
||||
});
|
||||
|
||||
const errorMessage = ref('');
|
||||
@@ -267,12 +268,19 @@ function buildCommonParams({ reason = committedFilters.reason } = {}) {
|
||||
return buildRejectCommonQueryParams(committedFilters, { reason });
|
||||
}
|
||||
|
||||
function metricFilterParam() {
|
||||
const mode = paretoMetricMode.value;
|
||||
if (mode === 'reject' || mode === 'defect') return mode;
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function buildListParams() {
|
||||
const effectiveReason = detailReason.value || committedFilters.reason;
|
||||
const params = {
|
||||
...buildCommonParams({ reason: effectiveReason }),
|
||||
page: page.value,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
metric_filter: metricFilterParam(),
|
||||
};
|
||||
if (selectedTrendDates.value.length > 0) {
|
||||
const sorted = [...selectedTrendDates.value].sort();
|
||||
@@ -561,7 +569,9 @@ function onTrendDateClick(dateStr) {
|
||||
|
||||
function onTrendLegendChange(selected) {
|
||||
trendLegendSelected.value = selected;
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadListOnly();
|
||||
}
|
||||
|
||||
function onParetoClick(reason) {
|
||||
@@ -625,7 +635,9 @@ async function removeFilterChip(chip) {
|
||||
await loadDataSections();
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
async function exportCsv() {
|
||||
if (loading.exporting) return;
|
||||
|
||||
const effectiveReason = detailReason.value || committedFilters.reason;
|
||||
const queryParams = buildRejectCommonQueryParams(committedFilters, { reason: effectiveReason });
|
||||
const params = new URLSearchParams();
|
||||
@@ -638,8 +650,33 @@ function exportCsv() {
|
||||
appendArrayParams(params, 'workcenter_groups', queryParams.workcenter_groups || []);
|
||||
appendArrayParams(params, 'packages', queryParams.packages || []);
|
||||
appendArrayParams(params, 'reasons', queryParams.reasons || []);
|
||||
params.set('metric_filter', metricFilterParam());
|
||||
|
||||
window.location.href = `/api/reject-history/export?${params.toString()}`;
|
||||
loading.exporting = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const response = await fetch(`/api/reject-history/export?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('匯出 CSV 失敗');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename=(.+?)(?:;|$)/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `reject_history_${queryParams.start_date}_to_${queryParams.end_date}.csv`;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '匯出 CSV 失敗';
|
||||
} finally {
|
||||
loading.exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
const totalScrapQty = computed(() => {
|
||||
|
||||
@@ -42,6 +42,8 @@ function formatNumber(value) {
|
||||
<th class="col-left">TYPE</th>
|
||||
<th>PRODUCT</th>
|
||||
<th>原因</th>
|
||||
<th>EQUIPMENT</th>
|
||||
<th>COMMENT</th>
|
||||
<th class="th-expandable" @click="showRejectBreakdown = !showRejectBreakdown">
|
||||
扣帳報廢量 <span class="expand-icon">{{ showRejectBreakdown ? '▾' : '▸' }}</span>
|
||||
</th>
|
||||
@@ -65,6 +67,8 @@ function formatNumber(value) {
|
||||
<td class="col-left">{{ row.PJ_TYPE }}</td>
|
||||
<td>{{ row.PRODUCTNAME || '' }}</td>
|
||||
<td>{{ row.LOSSREASONNAME }}</td>
|
||||
<td>{{ row.EQUIPMENTNAME || '' }}</td>
|
||||
<td>{{ row.REJECTCOMMENT || '' }}</td>
|
||||
<td>{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
|
||||
<template v-if="showRejectBreakdown">
|
||||
<td class="td-sub">{{ formatNumber(row.REJECT_QTY) }}</td>
|
||||
@@ -77,7 +81,7 @@ function formatNumber(value) {
|
||||
<td class="cell-nowrap">{{ row.TXN_TIME || row.TXN_DAY }}</td>
|
||||
</tr>
|
||||
<tr v-if="!items || items.length === 0">
|
||||
<td :colspan="showRejectBreakdown ? 15 : 10" class="placeholder">No data</td>
|
||||
<td :colspan="showRejectBreakdown ? 17 : 12" class="placeholder">No data</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -84,7 +84,10 @@ defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-primary" :disabled="loading.querying" @click="$emit('apply')">查詢</button>
|
||||
<button class="btn btn-secondary" :disabled="loading.querying" @click="$emit('clear')">清除條件</button>
|
||||
<button class="btn btn-light btn-export" :disabled="loading.querying" @click="$emit('export-csv')">匯出 CSV</button>
|
||||
<button class="btn btn-light btn-export" :disabled="loading.querying || loading.exporting" @click="$emit('export-csv')">
|
||||
<template v-if="loading.exporting"><span class="btn-spinner"></span>匯出中...</template>
|
||||
<template v-else>匯出 CSV</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ const chartOption = computed(() => {
|
||||
data: ['扣帳報廢量', '不扣帳報廢量'],
|
||||
bottom: 0,
|
||||
},
|
||||
grid: { left: 48, right: 24, top: 22, bottom: 70 },
|
||||
grid: { left: 48, right: 24, top: 22, bottom: 70, containLabel: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: items.map((item) => item.bucket_date || ''),
|
||||
@@ -97,7 +97,7 @@ function handleLegendChange(params) {
|
||||
<article class="card">
|
||||
<div class="card-header"><div class="card-title">報廢量趨勢</div></div>
|
||||
<div class="card-body chart-wrap">
|
||||
<VChart :option="chartOption" autoresize @click="handleChartClick" @legendselectchanged="handleLegendChange" />
|
||||
<VChart :option="chartOption" :autoresize="{ throttle: 100 }" style="width: 100%; height: 100%" @click="handleChartClick" @legendselectchanged="handleLegendChange" />
|
||||
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -166,6 +166,27 @@
|
||||
background: #0b5e59;
|
||||
}
|
||||
|
||||
.btn-export:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.reject-summary-row {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
@@ -191,8 +212,10 @@
|
||||
|
||||
.chart-wrap,
|
||||
.pareto-chart-wrap {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pareto-header {
|
||||
|
||||
Reference in New Issue
Block a user