chore(openspec): archive query-tool change and commit remaining updates
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
286
frontend/src/query-tool/components/LotJobsTable.vue
Normal file
286
frontend/src/query-tool/components/LotJobsTable.vue
Normal 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>
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user