chore(openspec): archive query-tool change and commit remaining updates

This commit is contained in:
egg
2026-02-23 07:10:51 +08:00
parent 58e4c87fb6
commit 1c46f5eb69
16 changed files with 352 additions and 17 deletions

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue';
import ExportButton from './ExportButton.vue'; import ExportButton from './ExportButton.vue';
import LotAssociationTable from './LotAssociationTable.vue'; import LotAssociationTable from './LotAssociationTable.vue';
import LotHistoryTable from './LotHistoryTable.vue'; import LotHistoryTable from './LotHistoryTable.vue';
import LotJobsTable from './LotJobsTable.vue';
import LotRejectTable from './LotRejectTable.vue'; import LotRejectTable from './LotRejectTable.vue';
import LotTimeline from './LotTimeline.vue'; import LotTimeline from './LotTimeline.vue';
@@ -73,7 +74,7 @@ const tabMeta = Object.freeze({
materials: { label: '原物料', emptyText: '無原物料資料' }, materials: { label: '原物料', emptyText: '無原物料資料' },
rejects: { label: '報廢', emptyText: '無報廢資料' }, rejects: { label: '報廢', emptyText: '無報廢資料' },
holds: { label: 'Hold', emptyText: '無 Hold 資料' }, holds: { label: 'Hold', emptyText: '無 Hold 資料' },
jobs: { label: 'Job', emptyText: '無 Job 資料' }, jobs: { label: '維修', emptyText: '無維修資料' },
}); });
const subTabs = Object.keys(tabMeta); const subTabs = Object.keys(tabMeta);
@@ -248,7 +249,7 @@ const detailCountLabel = computed(() => {
</div> </div>
<LotAssociationTable <LotAssociationTable
v-else-if="activeSubTab !== 'rejects'" v-else-if="activeSubTab !== 'rejects' && activeSubTab !== 'jobs'"
:rows="activeRows" :rows="activeRows"
:loading="activeLoading" :loading="activeLoading"
:empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'" :empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'"
@@ -257,6 +258,13 @@ const detailCountLabel = computed(() => {
:column-order="activeColumnOrder" :column-order="activeColumnOrder"
/> />
<LotJobsTable
v-else-if="activeSubTab === 'jobs'"
:rows="activeRows"
:loading="activeLoading"
:empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'"
/>
<LotRejectTable <LotRejectTable
v-else v-else
:rows="activeRows" :rows="activeRows"

View File

@@ -0,0 +1,286 @@
<script setup>
import { computed, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../../core/api.js';
import StatusBadge from '../../shared-ui/components/StatusBadge.vue';
import { formatCellValue, formatDateTime, parseDateTime } from '../utils/values.js';
const props = defineProps({
rows: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
emptyText: {
type: String,
default: '無維修資料',
},
});
ensureMesApiAvailable();
const JOB_COLUMN_PRIORITY = Object.freeze([
'JOBID',
'RESOURCEID',
'RESOURCENAME',
'JOBSTATUS',
'JOBMODELNAME',
'JOBORDERNAME',
'CREATEDATE',
'COMPLETEDATE',
'CANCELDATE',
'FIRSTCLOCKONDATE',
'LASTCLOCKOFFDATE',
'CAUSECODENAME',
'REPAIRCODENAME',
'SYMPTOMCODENAME',
'PJ_CAUSECODE2NAME',
'PJ_REPAIRCODE2NAME',
'PJ_SYMPTOMCODE2NAME',
'CREATE_EMPNAME',
'COMPLETE_EMPNAME',
'CONTAINERIDS',
'CONTAINERNAMES',
]);
const TXN_COLUMN_PRIORITY = Object.freeze([
'JOBTXNHISTORYID',
'JOBID',
'TXNDATE',
'FROMJOBSTATUS',
'JOBSTATUS',
'STAGENAME',
'TOSTAGENAME',
'CAUSECODENAME',
'REPAIRCODENAME',
'SYMPTOMCODENAME',
'USER_EMPNO',
'USER_NAME',
'EMP_EMPNO',
'EMP_NAME',
'COMMENTS',
'CDONAME',
'JOBMODELNAME',
'JOBORDERNAME',
]);
const selectedJobId = ref('');
const txnRows = ref([]);
const loadingTxn = ref(false);
const txnError = ref('');
function buildOrderedColumns(rows, preferred) {
const keys = Object.keys(rows?.[0] || {});
if (keys.length === 0) {
return [...preferred];
}
const keySet = new Set(keys);
const ordered = preferred.filter((column) => keySet.has(column));
const orderedSet = new Set(ordered);
keys.forEach((column) => {
if (!orderedSet.has(column)) {
ordered.push(column);
}
});
return ordered;
}
const sortedRows = computed(() => {
return [...(props.rows || [])].sort((a, b) => {
const aDate = parseDateTime(a?.CREATEDATE);
const bDate = parseDateTime(b?.CREATEDATE);
const aTime = aDate ? aDate.getTime() : 0;
const bTime = bDate ? bDate.getTime() : 0;
return bTime - aTime;
});
});
const jobColumns = computed(() => {
return buildOrderedColumns(props.rows, JOB_COLUMN_PRIORITY);
});
const txnColumns = computed(() => {
return buildOrderedColumns(txnRows.value, TXN_COLUMN_PRIORITY);
});
function rowKey(row, index) {
return String(row?.JOBID || `${row?.RESOURCEID || ''}-${index}`);
}
function buildStatusTone(status) {
const text = String(status || '').trim().toLowerCase();
if (!text) {
return 'neutral';
}
if (['complete', 'completed', 'done', 'closed', 'finish'].some((keyword) => text.includes(keyword))) {
return 'success';
}
if (['open', 'pending', 'queue', 'wait', 'hold', 'in progress'].some((keyword) => text.includes(keyword))) {
return 'warning';
}
if (['cancel', 'abort', 'fail', 'error'].some((keyword) => text.includes(keyword))) {
return 'danger';
}
return 'neutral';
}
function renderJobCellValue(row, column) {
if (column === 'CREATEDATE' || column === 'COMPLETEDATE') {
return formatDateTime(row?.[column]);
}
return formatCellValue(row?.[column]);
}
function renderTxnCellValue(row, column) {
const normalizedColumn = String(column || '').toUpperCase();
if (normalizedColumn.includes('DATE') || normalizedColumn.includes('TIME')) {
return formatDateTime(row?.[column]);
}
if (column === 'USER_NAME') {
return formatCellValue(row?.USER_NAME || row?.EMP_NAME);
}
return formatCellValue(row?.[column]);
}
async function loadTxn(jobId) {
const id = String(jobId || '').trim();
if (!id) {
return;
}
selectedJobId.value = id;
loadingTxn.value = true;
txnError.value = '';
txnRows.value = [];
try {
const payload = await apiGet(`/api/job-query/txn/${encodeURIComponent(id)}`, {
timeout: 60000,
silent: true,
});
txnRows.value = Array.isArray(payload?.data) ? payload.data : [];
} catch (error) {
txnError.value = error?.message || '載入交易歷程失敗';
txnRows.value = [];
} finally {
loadingTxn.value = false;
}
}
</script>
<template>
<section class="space-y-3">
<div class="rounded-card border border-stroke-soft bg-white p-3">
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
讀取中...
</div>
<div v-else-if="sortedRows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
{{ emptyText }}
</div>
<div v-else class="max-h-[420px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<tr>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">操作</th>
<th
v-for="column in jobColumns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in sortedRows" :key="rowKey(row, rowIndex)" class="odd:bg-white even:bg-slate-50">
<td class="border-b border-stroke-soft/70 px-2 py-1.5">
<button
type="button"
class="rounded-card border border-stroke-soft bg-white px-2 py-1 text-[11px] font-medium text-slate-600 transition hover:bg-surface-muted/70 hover:text-slate-800"
@click="loadTxn(row?.JOBID)"
>
查看交易歷程
</button>
</td>
<td
v-for="column in jobColumns"
:key="`${rowKey(row, rowIndex)}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<StatusBadge
v-if="column === 'JOBSTATUS'"
:tone="buildStatusTone(row?.[column])"
:text="formatCellValue(row?.[column])"
/>
<span v-else>{{ renderJobCellValue(row, column) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="selectedJobId" class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">交易歷程{{ selectedJobId }}</h4>
<span class="text-xs text-slate-500">{{ txnRows.length }} </span>
</div>
<p v-if="txnError" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
{{ txnError }}
</p>
<div v-if="loadingTxn" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
載入交易歷程中...
</div>
<div v-else-if="txnRows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
無交易歷程資料
</div>
<div v-else class="max-h-[420px] overflow-auto rounded-card border border-stroke-soft">
<table class="min-w-full border-collapse text-xs">
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
<tr>
<th
v-for="column in txnColumns"
:key="column"
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
>
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in txnRows"
:key="row?.JOBTXNHISTORYID || `${selectedJobId}-${rowIndex}`"
class="odd:bg-white even:bg-slate-50"
>
<td
v-for="column in txnColumns"
:key="`${row?.JOBTXNHISTORYID || rowIndex}-${column}`"
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
>
<StatusBadge
v-if="column === 'JOBSTATUS' || column === 'FROMJOBSTATUS'"
:tone="buildStatusTone(row?.[column])"
:text="formatCellValue(row?.[column])"
/>
<span v-else>{{ renderTxnCellValue(row, column) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>

View File

@@ -4,7 +4,7 @@
TBD - created by archiving change reject-history-query-page. Update Purpose after archive. TBD - created by archiving change reject-history-query-page. Update Purpose after archive.
## Requirements ## Requirements
### Requirement: Reject History page SHALL provide filterable historical query controls ### Requirement: Reject History page SHALL provide filterable historical query controls
The page SHALL provide a filter area for date range and major production dimensions to drive all report sections, and SHALL provide context-aware option narrowing for exploratory filtering. The page SHALL provide a filter area for date range and major production dimensions to drive all report sections.
#### Scenario: Default filter values #### Scenario: Default filter values
- **WHEN** the page is first loaded - **WHEN** the page is first loaded
@@ -23,20 +23,11 @@ The page SHALL provide a filter area for date range and major production dimensi
- **THEN** it SHALL include reason filter control - **THEN** it SHALL include reason filter control
- **THEN** it SHALL include `WORKCENTER_GROUP` filter control - **THEN** it SHALL include `WORKCENTER_GROUP` filter control
#### Scenario: Draft filter options are interdependent #### Scenario: Header refresh button
- **WHEN** user changes draft values for `WORKCENTER_GROUP`, `package`, `reason`, or policy toggles - **WHEN** the page header is rendered
- **THEN** option candidates for reason/workcenter-group/package SHALL reload under the current draft context - **THEN** it SHALL include a "重新整理" button in the header-right area
- **THEN** unavailable combinations SHALL NOT remain in selectable options - **WHEN** user clicks the refresh button
- **THEN** all sections SHALL reload with current filters (equivalent to "查詢")
#### Scenario: Policy toggles affect option scope
- **WHEN** user changes policy toggles (including excluded-scrap and material-scrap switches)
- **THEN** options and query results SHALL use the same policy mode
- **THEN** option narrowing SHALL remain consistent with backend exclusion semantics
#### Scenario: Invalid selected values are pruned
- **WHEN** narrowed options no longer contain previously selected values
- **THEN** invalid selections SHALL be removed automatically before query commit
- **THEN** apply/query SHALL only send valid selected values
### Requirement: Reject History page SHALL expose yield-exclusion toggle control ### Requirement: Reject History page SHALL expose yield-exclusion toggle control
The page SHALL let users decide whether to include policy-marked scrap in yield calculations. The page SHALL let users decide whether to include policy-marked scrap in yield calculations.
@@ -154,3 +145,53 @@ The page SHALL keep the same semantic grouping across desktop and mobile layouts
- **WHEN** viewport width is below responsive breakpoint - **WHEN** viewport width is below responsive breakpoint
- **THEN** cards and chart panels SHALL stack in a single column - **THEN** cards and chart panels SHALL stack in a single column
- **THEN** filter controls SHALL remain operable without horizontal overflow - **THEN** filter controls SHALL remain operable without horizontal overflow
### Requirement: Reject History page SHALL display a loading overlay during initial data load
The page SHALL show a full-screen loading overlay with spinner during the first data load to provide clear feedback.
#### Scenario: Loading overlay on initial mount
- **WHEN** the page first mounts and `loadAllData` begins
- **THEN** a loading overlay with spinner SHALL be displayed over the page content
- **WHEN** all initial API responses complete
- **THEN** the overlay SHALL be hidden
#### Scenario: Subsequent queries do not show overlay
- **WHEN** the user triggers a re-query after initial load
- **THEN** no full-screen overlay SHALL appear (inline loading states are sufficient)
### Requirement: Detail table rows SHALL highlight on hover
The detail table and pareto table rows SHALL visually respond to mouse hover for improved readability.
#### Scenario: Row hover in detail table
- **WHEN** user hovers over a row in the detail table
- **THEN** the row background SHALL change to a subtle highlight color
#### Scenario: Row hover in pareto table
- **WHEN** user hovers over a row in the pareto summary table
- **THEN** the row background SHALL change to a subtle highlight color
### Requirement: Pagination controls SHALL use Chinese labels
The detail list pagination SHALL display controls in Chinese to match the rest of the page language.
#### Scenario: Pagination button labels
- **WHEN** the pagination controls are rendered
- **THEN** the previous-page button SHALL display "上一頁"
- **THEN** the next-page button SHALL display "下一頁"
- **THEN** the page info text SHALL use Chinese formatting (e.g., "第 1 / 5 頁 · 共 250 筆")
### Requirement: Reject History page SHALL be structured as modular sub-components
The page template SHALL delegate sections to focused sub-components, following the hold-history architecture pattern.
#### Scenario: Component decomposition
- **WHEN** the page source is examined
- **THEN** the filter panel SHALL be a separate `FilterPanel.vue` component
- **THEN** the KPI summary cards SHALL be a separate `SummaryCards.vue` component
- **THEN** the trend chart SHALL be a separate `TrendChart.vue` component
- **THEN** the pareto section (chart + table) SHALL be a separate `ParetoSection.vue` component
- **THEN** the detail table with pagination SHALL be a separate `DetailTable.vue` component
#### Scenario: App.vue acts as orchestrator
- **WHEN** the page runs
- **THEN** `App.vue` SHALL hold all reactive state and API logic
- **THEN** sub-components SHALL receive data via props and communicate via events