feat(reject-history): ship report page and archive openspec change
This commit is contained in:
@@ -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')],
|
||||
|
||||
@@ -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',
|
||||
|
||||
968
frontend/src/reject-history/App.vue
Normal file
968
frontend/src/reject-history/App.vue
Normal 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>
|
||||
12
frontend/src/reject-history/index.html
Normal file
12
frontend/src/reject-history/index.html
Normal 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>
|
||||
7
frontend/src/reject-history/main.js
Normal file
7
frontend/src/reject-history/main.js
Normal 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');
|
||||
418
frontend/src/reject-history/style.css
Normal file
418
frontend/src/reject-history/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user