Просмотр исходного кода

updaate import bom

reset-do-picking-order
CANCERYS\kw093 1 неделю назад
Родитель
Сommit
d7a34cf064
17 измененных файлов: 633 добавлений и 1718 удалений
  1. +45
    -1
      src/app/api/bom/client.ts
  2. +42
    -0
      src/app/api/bom/index.ts
  3. +22
    -4
      src/app/api/settings/qcItemAll/actions.ts
  4. +7
    -1
      src/app/api/settings/qcItemAll/index.ts
  5. +0
    -1039
      src/components/ImportBom/EquipmentSearch.tsx
  6. +0
    -41
      src/components/ImportBom/EquipmentSearchLoading.tsx
  7. +0
    -492
      src/components/ImportBom/EquipmentSearchResults.tsx
  8. +0
    -35
      src/components/ImportBom/EquipmentSearchWrapper.tsx
  9. +177
    -0
      src/components/ImportBom/ImportBomDetailTab.tsx
  10. +75
    -42
      src/components/ImportBom/ImportBomResultForm.tsx
  11. +45
    -10
      src/components/ImportBom/ImportBomUpload.tsx
  12. +65
    -35
      src/components/ImportBom/ImportBomWrapper.tsx
  13. +3
    -1
      src/components/QcCategorySave/QcCategoryDetails.tsx
  14. +146
    -13
      src/components/QcItemAll/Tab0ItemQcCategoryMapping.tsx
  15. +3
    -2
      src/components/QcItemAll/Tab1QcCategoryQcItemMapping.tsx
  16. +2
    -1
      src/components/QcItemAll/Tab2QcCategoryManagement.tsx
  17. +1
    -1
      src/components/QcItemAll/Tab3QcItemManagement.tsx

+ 45
- 1
src/app/api/bom/client.ts Просмотреть файл

@@ -6,6 +6,8 @@ import type {
BomFormatCheckResponse, BomFormatCheckResponse,
BomUploadResponse, BomUploadResponse,
ImportBomItemPayload, ImportBomItemPayload,
BomCombo,
BomDetailResponse,
} from "./index"; } from "./index";


export async function uploadBomFiles( export async function uploadBomFiles(
@@ -39,7 +41,19 @@ export async function checkBomFormat(
); );
return response.data; return response.data;
} }

export async function downloadBomFormatIssueLog(
batchId: string,
issueLogFileId: string
): Promise<Blob> {
const response = await axiosInstance.get(
`${NEXT_PUBLIC_API_URL}/bom/import-bom/format-issue-log`,
{
params: { batchId, issueLogFileId },
responseType: "blob",
}
);
return response.data as Blob;
}
export async function importBom( export async function importBom(
batchId: string, batchId: string,
items: ImportBomItemPayload[] items: ImportBomItemPayload[]
@@ -60,3 +74,33 @@ export const fetchBomScoresClient = async (): Promise<BomScoreResult[]> => {
return response.data; return response.data;
}; };


export async function fetchBomComboClient(): Promise<BomCombo[]> {
const response = await axiosInstance.get<BomCombo[]>(
`${NEXT_PUBLIC_API_URL}/bom/combo`
);
return response.data;
}
export async function fetchBomDetailClient(id: number): Promise<BomDetailResponse> {
const response = await axiosInstance.get<BomDetailResponse>(
`${NEXT_PUBLIC_API_URL}/bom/${id}/detail`
);
return response.data;
}
export type BomExcelCheckProgress = {
batchId: string;
totalFiles: number;
processedFiles: number;
currentFileName: string | null;
lastUpdateTime: number;
};
export async function getBomFormatProgress(
batchId: string
): Promise<BomExcelCheckProgress> {
const response = await axiosInstance.get<BomExcelCheckProgress>(
`${NEXT_PUBLIC_API_URL}/bom/import-bom/format-check/progress`,
{ params: { batchId } }
);
return response.data;
}

+ 42
- 0
src/app/api/bom/index.ts Просмотреть файл

@@ -20,6 +20,7 @@ export interface BomFormatFileGroup {
export interface BomFormatCheckResponse { export interface BomFormatCheckResponse {
correctFileNames: string[]; correctFileNames: string[];
failList: BomFormatFileGroup[]; failList: BomFormatFileGroup[];
issueLogFileId: string;
} }


export interface BomUploadResponse { export interface BomUploadResponse {
@@ -30,6 +31,7 @@ export interface BomUploadResponse {
export interface ImportBomItemPayload { export interface ImportBomItemPayload {
fileName: string; fileName: string;
isAlsoWip: boolean; isAlsoWip: boolean;
isDrink: boolean;
} }


export const preloadBomCombo = (() => { export const preloadBomCombo = (() => {
@@ -56,3 +58,43 @@ export const fetchBomScores = cache(async () => {
}); });
}); });


export interface BomMaterialDto {
itemCode?: string;
itemName?: string;
baseQty?: number;
baseUom?: string;
stockQty?: number;
stockUom?: string;
salesQty?: number;
salesUom?: string;
}

export interface BomProcessDto {
seqNo?: number;
processName?: string;
processDescription?: string;
equipmentName?: string;
durationInMinute?: number;
prepTimeInMinute?: number;
postProdTimeInMinute?: number;
}

export interface BomDetailResponse {
id: number;
itemCode?: string;
itemName?: string;
isDark?: boolean;
isFloat?: number;
isDense?: number;
isDrink?: boolean;
scrapRate?: number;
allergicSubstances?: number;
timeSequence?: number;
complexity?: number;
baseScore?: number;
description?: string;
outputQty?: number;
outputQtyUom?: string;
materials: BomMaterialDto[];
processes: BomProcessDto[];
}

+ 22
- 4
src/app/api/settings/qcItemAll/actions.ts Просмотреть файл

@@ -1,6 +1,6 @@
"use server"; "use server";


import { serverFetchJson } from "@/app/utils/fetchUtil";
import { serverFetchJson ,serverFetchWithNoContent} from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api"; import { BASE_API_URL } from "@/config/api";
import { revalidatePath, revalidateTag } from "next/cache"; import { revalidatePath, revalidateTag } from "next/cache";
import { import {
@@ -234,11 +234,29 @@ export const deleteQcItemWithValidation = async (


// Server actions for fetching data (to be used in client components) // Server actions for fetching data (to be used in client components)
export const fetchQcCategoriesForAll = async (): Promise<QcCategoryResult[]> => { export const fetchQcCategoriesForAll = async (): Promise<QcCategoryResult[]> => {
return serverFetchJson<QcCategoryResult[]>(`${BASE_API_URL}/qcCategories`, {
next: { tags: ["qcCategories"] },
});
return serverFetchJson<QcCategoryResult[]>(
`${BASE_API_URL}/qcItemAll/categoriesWithItemCountsAndType`,
{ next: { tags: ["qcItemAll", "qcCategories"] } }
);
};
type CategoryTypeResponse = { type: string | null };
export const getCategoryType = async (qcCategoryId: number): Promise<string | null> => {
const res = await serverFetchJson<CategoryTypeResponse>(
`${BASE_API_URL}/qcItemAll/categoryType/${qcCategoryId}`
);
return res.type ?? null;
}; };


export const updateCategoryType = async (
qcCategoryId: number,
type: string
): Promise<void> => {
await serverFetchWithNoContent(
`${BASE_API_URL}/qcItemAll/categoryType?qcCategoryId=${qcCategoryId}&type=${encodeURIComponent(type)}`,
{ method: "PUT" }
);
revalidateTag("qcItemAll");
};
export const fetchItemsForAll = async (): Promise<ItemsResult[]> => { export const fetchItemsForAll = async (): Promise<ItemsResult[]> => {
return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, {
next: { tags: ["items"] }, next: { tags: ["items"] },


+ 7
- 1
src/app/api/settings/qcItemAll/index.ts Просмотреть файл

@@ -9,7 +9,13 @@ export interface ItemQcCategoryMappingInfo {
qcCategoryName?: string; qcCategoryName?: string;
type?: string; type?: string;
} }

export interface QcCategoryResult {
id: number;
code: string;
name: string;
description?: string;
type?: string | null; // add this: items_qc_category_mapping.type for this category
}
export interface QcItemInfo { export interface QcItemInfo {
id: number; id: number;
order: number; order: number;


+ 0
- 1039
src/components/ImportBom/EquipmentSearch.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 0
- 41
src/components/ImportBom/EquipmentSearchLoading.tsx Просмотреть файл

@@ -1,41 +0,0 @@
"use client";

import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

export const EquipmentTypeSearchLoading: React.FC = () => {
return (
<>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton
variant="rounded"
height={50}
width={100}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
</Stack>
</CardContent>
</Card>
</>
);
};

export default EquipmentTypeSearchLoading;

+ 0
- 492
src/components/ImportBom/EquipmentSearchResults.tsx Просмотреть файл

@@ -1,492 +0,0 @@
"use client";

import React, {
ChangeEvent,
Dispatch,
MouseEvent,
SetStateAction,
useCallback,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import Paper from "@mui/material/Paper";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell, { TableCellProps } from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TablePagination, {
TablePaginationProps,
} from "@mui/material/TablePagination";
import TableRow from "@mui/material/TableRow";
import IconButton, { IconButtonOwnProps } from "@mui/material/IconButton";
import {
ButtonOwnProps,
Checkbox,
Icon,
IconOwnProps,
SxProps,
Theme,
} from "@mui/material";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil";
import { filter, remove, uniq } from "lodash";

export interface ResultWithId {
id: string | number;
}

type ColumnType = "icon" | "decimal" | "integer" | "checkbox";

interface BaseColumn<T extends ResultWithId> {
name: keyof T;
label: string;
align?: TableCellProps["align"];
headerAlign?: TableCellProps["align"];
sx?: SxProps<Theme> | undefined;
style?: Partial<HTMLElement["style"]> & { [propName: string]: string };
type?: ColumnType;
renderCell?: (params: T) => React.ReactNode;
renderHeader?: () => React.ReactNode;
}

interface IconColumn<T extends ResultWithId> extends BaseColumn<T> {
name: keyof T;
type: "icon";
icon?: React.ReactNode;
icons?: { [columnValue in keyof T]: React.ReactNode };
color?: IconOwnProps["color"];
colors?: { [columnValue in keyof T]: IconOwnProps["color"] };
}

interface DecimalColumn<T extends ResultWithId> extends BaseColumn<T> {
type: "decimal";
}

interface IntegerColumn<T extends ResultWithId> extends BaseColumn<T> {
type: "integer";
}

interface CheckboxColumn<T extends ResultWithId> extends BaseColumn<T> {
type: "checkbox";
disabled?: (params: T) => boolean;
// checkboxIds: readonly (string | number)[],
// setCheckboxIds: (ids: readonly (string | number)[]) => void
}

interface ColumnWithAction<T extends ResultWithId> extends BaseColumn<T> {
onClick: (item: T) => void;
buttonIcon: React.ReactNode;
buttonIcons: { [columnValue in keyof T]: React.ReactNode };
buttonColor?: IconButtonOwnProps["color"];
}

export type Column<T extends ResultWithId> =
| BaseColumn<T>
| IconColumn<T>
| DecimalColumn<T>
| CheckboxColumn<T>
| ColumnWithAction<T>;

interface Props<T extends ResultWithId> {
totalCount?: number;
items: T[];
columns: Column<T>[];
noWrapper?: boolean;
setPagingController?: Dispatch<
SetStateAction<{
pageNum: number;
pageSize: number;
}>
>;
pagingController?: { pageNum: number; pageSize: number };
isAutoPaging?: boolean;
checkboxIds?: (string | number)[];
setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>;
onRowClick?: (item: T) => void;
renderExpandedRow?: (item: T) => React.ReactNode;
hideHeader?: boolean;
}

function isActionColumn<T extends ResultWithId>(
column: Column<T>,
): column is ColumnWithAction<T> {
return Boolean((column as ColumnWithAction<T>).onClick);
}

function isIconColumn<T extends ResultWithId>(
column: Column<T>,
): column is IconColumn<T> {
return column.type === "icon";
}

function isDecimalColumn<T extends ResultWithId>(
column: Column<T>,
): column is DecimalColumn<T> {
return column.type === "decimal";
}

function isIntegerColumn<T extends ResultWithId>(
column: Column<T>,
): column is IntegerColumn<T> {
return column.type === "integer";
}

function isCheckboxColumn<T extends ResultWithId>(
column: Column<T>,
): column is CheckboxColumn<T> {
return column.type === "checkbox";
}

function convertObjectKeysToLowercase<T extends object>(
obj: T,
): object | undefined {
return obj
? Object.fromEntries(
Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]),
)
: undefined;
}

function handleIconColors<T extends ResultWithId>(
column: IconColumn<T>,
value: T[keyof T],
): IconOwnProps["color"] {
const colors = convertObjectKeysToLowercase(column.colors ?? {});
const valueKey = String(value).toLowerCase() as keyof typeof colors;

if (colors && valueKey in colors) {
return colors[valueKey];
}

return column.color ?? "primary";
}

function handleIconIcons<T extends ResultWithId>(
column: IconColumn<T>,
value: T[keyof T],
): React.ReactNode {
const icons = convertObjectKeysToLowercase(column.icons ?? {});
const valueKey = String(value).toLowerCase() as keyof typeof icons;

if (icons && valueKey in icons) {
return icons[valueKey];
}

return column.icon ?? <CheckCircleOutlineIcon fontSize="small" />;
}
export const defaultPagingController: { pageNum: number; pageSize: number } = {
pageNum: 1,
pageSize: 10,
};

export type defaultSetPagingController = Dispatch<
SetStateAction<{
pageNum: number;
pageSize: number;
}>
>

function EquipmentSearchResults<T extends ResultWithId>({
items,
columns,
noWrapper,
pagingController,
setPagingController,
isAutoPaging = true,
totalCount,
checkboxIds = [],
setCheckboxIds = undefined,
onRowClick = undefined,
renderExpandedRow = undefined,
hideHeader = false,
}: Props<T>) {
const { t } = useTranslation("common");
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);
const handleChangePage: TablePaginationProps["onPageChange"] = (
_event,
newPage,
) => {
console.log(_event);
setPage(newPage);
if (setPagingController) {
setPagingController({
...(pagingController ?? defaultPagingController),
pageNum: newPage + 1,
});
}
};

const handleChangeRowsPerPage: TablePaginationProps["onRowsPerPageChange"] = (
event,
) => {
console.log(event);
const newSize = +event.target.value;
setRowsPerPage(newSize);
setPage(0);
if (setPagingController) {
setPagingController({
...(pagingController ?? defaultPagingController),
pageNum: 1,
pageSize: newSize,
});
}
};

const currItems = useMemo(() => {
return items.length > 10 ? items
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((i) => i.id)
: items.map((i) => i.id)
}, [items, page, rowsPerPage])

const currItemsWithChecked = useMemo(() => {
return filter(checkboxIds, function (c) {
return currItems.includes(c);
})
}, [checkboxIds, items, page, rowsPerPage])

const handleRowClick = useCallback(
(event: MouseEvent<unknown>, item: T, columns: Column<T>[]) => {
let disabled = false;
columns.forEach((col) => {
if (isCheckboxColumn(col) && col.disabled) {
disabled = col.disabled(item);
if (disabled) {
return;
}
}
});

if (disabled) {
return;
}

const id = item.id;
if (setCheckboxIds) {
const selectedIndex = checkboxIds.indexOf(id);
let newSelected: (string | number)[] = [];

if (selectedIndex === -1) {
newSelected = newSelected.concat(checkboxIds, id);
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(checkboxIds.slice(1));
} else if (selectedIndex === checkboxIds.length - 1) {
newSelected = newSelected.concat(checkboxIds.slice(0, -1));
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(
checkboxIds.slice(0, selectedIndex),
checkboxIds.slice(selectedIndex + 1),
);
}
setCheckboxIds(newSelected);
}
},
[checkboxIds, setCheckboxIds],
);

const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (setCheckboxIds) {
const pageItemId = currItems

if (event.target.checked) {
setCheckboxIds((prev) => uniq([...prev, ...pageItemId]))
} else {
setCheckboxIds((prev) => filter(prev, function (p) { return !pageItemId.includes(p); }))
}
}
}

const table = (
<>
<TableContainer sx={{ maxHeight: 440 }}>
<Table stickyHeader={!hideHeader}>
{!hideHeader && (
<TableHead>
<TableRow>
{columns.map((column, idx) => (
isCheckboxColumn(column) ?
<TableCell
align={column.headerAlign}
sx={column.sx}
key={`${column.name.toString()}${idx}`}
>
<Checkbox
color="primary"
indeterminate={currItemsWithChecked.length > 0 && currItemsWithChecked.length < currItems.length}
checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length}
onChange={handleSelectAllClick}
/>
</TableCell>
: <TableCell
align={column.headerAlign}
sx={column.sx}
key={`${column.name.toString()}${idx}`}
>
{column.renderHeader ? (
column.renderHeader()
) : (
column.label.split('\n').map((line, index) => (
<div key={index}>{line}</div>
))
)}
</TableCell>
))}
</TableRow>
</TableHead>
)}
<TableBody>
{isAutoPaging
? items
.slice((pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage),
(pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage))
.map((item) => {
return (
<React.Fragment key={item.id}>
<TableRow
hover
tabIndex={-1}
onClick={(event) => {
setCheckboxIds
? handleRowClick(event, item, columns)
: undefined

if (onRowClick) {
onRowClick(item)
}
}
}
role={setCheckboxIds ? "checkbox" : undefined}
>
{columns.map((column, idx) => {
const columnName = column.name;

return (
<TabelCells
key={`${columnName.toString()}-${idx}`}
column={column}
columnName={columnName}
idx={idx}
item={item}
checkboxIds={checkboxIds}
/>
);
})}
</TableRow>
{renderExpandedRow && renderExpandedRow(item)}
</React.Fragment>
);
})
: items.map((item) => {
return (
<React.Fragment key={item.id}>
<TableRow hover tabIndex={-1}
onClick={(event) => {
setCheckboxIds
? handleRowClick(event, item, columns)
: undefined

if (onRowClick) {
onRowClick(item)
}
}
}
role={setCheckboxIds ? "checkbox" : undefined}
>
{columns.map((column, idx) => {
const columnName = column.name;

return (
<TabelCells
key={`${columnName.toString()}-${idx}`}
column={column}
columnName={columnName}
idx={idx}
item={item}
checkboxIds={checkboxIds}
/>
);
})}
</TableRow>
{renderExpandedRow && renderExpandedRow(item)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
count={!totalCount || totalCount == 0 ? items.length : totalCount}
rowsPerPage={pagingController?.pageSize ? pagingController?.pageSize : rowsPerPage}
page={pagingController?.pageNum ? pagingController?.pageNum - 1 : page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
</>
);

return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>;
}

interface TableCellsProps<T extends ResultWithId> {
column: Column<T>;
columnName: keyof T;
idx: number;
item: T;
checkboxIds: (string | number)[];
}

function TabelCells<T extends ResultWithId>({
column,
columnName,
idx,
item,
checkboxIds = [],
}: TableCellsProps<T>) {
const isItemSelected = checkboxIds.includes(item.id);

return (
<TableCell
align={column.align}
sx={column.sx}
key={`${columnName.toString()}-${idx}`}
>
{isActionColumn(column) ? (
<IconButton
color={column.buttonColor ?? "primary"}
onClick={() => column.onClick(item)}
>
{column.buttonIcon}
</IconButton>
) : isIconColumn(column) ? (
<Icon color={handleIconColors(column, item[columnName])}>
{handleIconIcons(column, item[columnName])}
</Icon>
) : isDecimalColumn(column) ? (
<>{decimalFormatter.format(Number(item[columnName]))}</>
) : isIntegerColumn(column) ? (
<>{integerFormatter.format(Number(item[columnName]))}</>
) : isCheckboxColumn(column) ? (
<Checkbox
disabled={column.disabled ? column.disabled(item) : undefined}
checked={isItemSelected}
/>
) : column.renderCell ? (
column.renderCell(item)
) : (
<>{item[columnName] as string}</>
)}
</TableCell>
);
}

export default EquipmentSearchResults;

+ 0
- 35
src/components/ImportBom/EquipmentSearchWrapper.tsx Просмотреть файл

@@ -1,35 +0,0 @@
"use client";

import { useState, useEffect } from "react";
import EquipmentSearch from "./EquipmentSearch";
import EquipmentSearchLoading from "./EquipmentSearchLoading";
import EquipmentTabs from "@/app/(main)/settings/equipment/EquipmentTabs";
import { useSearchParams } from "next/navigation";

interface SubComponents {
Loading: typeof EquipmentSearchLoading;
}

const EquipmentSearchWrapper: React.FC & SubComponents = () => {
const searchParams = useSearchParams();
const tabFromUrl = searchParams.get("tab");
const initialTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0;
const [tabIndex, setTabIndex] = useState(initialTabIndex);

useEffect(() => {
const tabFromUrl = searchParams.get("tab");
const newTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0;
setTabIndex(newTabIndex);
}, [searchParams]);

return (
<>
<EquipmentTabs onTabChange={setTabIndex} />
<EquipmentSearch equipments={[]} tabIndex={tabIndex} />
</>
);
};

EquipmentSearchWrapper.Loading = EquipmentSearchLoading;

export default EquipmentSearchWrapper;

+ 177
- 0
src/components/ImportBom/ImportBomDetailTab.tsx Просмотреть файл

@@ -0,0 +1,177 @@
"use client";

import React, { useEffect, useState } from "react";
import {
Box,
Stack,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Paper,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
} from "@mui/material";
import type { BomCombo, BomDetailResponse } from "@/app/api/bom";
import { fetchBomComboClient, fetchBomDetailClient } from "@/app/api/bom/client";
import type { SelectChangeEvent } from "@mui/material/Select";
import { useTranslation } from "react-i18next";
const ImportBomDetailTab: React.FC = () => {
const { t } = useTranslation( "common" );
const [bomList, setBomList] = useState<BomCombo[]>([]);
const [selectedBomId, setSelectedBomId] = useState<number | "">("");
const [detail, setDetail] = useState<BomDetailResponse | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);

useEffect(() => {
const loadList = async () => {
setLoadingList(true);
try {
const list = await fetchBomComboClient();
setBomList(list);
} finally {
setLoadingList(false);
}
};
loadList();
}, []);

const handleChangeBom = async (event: SelectChangeEvent<number>) => {
const id = Number(event.target.value);
setSelectedBomId(id);
setDetail(null);
if (!id) return;
setLoadingDetail(true);
try {
const d = await fetchBomDetailClient(id);
setDetail(d);
} finally {
setLoadingDetail(false);
}
};

return (
<Stack spacing={2}>
<FormControl size="small" sx={{ minWidth: 320 }}>
<InputLabel id="import-bom-detail-select-label">
{t("Please Select BOM")}
</InputLabel>
<Select
labelId="import-bom-detail-select-label"
label="請選擇 BOM"
value={selectedBomId}
onChange={handleChangeBom}
>
{loadingList && (
<MenuItem value="">
<CircularProgress size={20} sx={{ mr: 1 }} /> 載入中…
</MenuItem>
)}
{!loadingList &&
bomList.map((b) => (
<MenuItem key={b.id} value={b.id}>
{b.label}
</MenuItem>
))}
</Select>
</FormControl>

{loadingDetail && (
<Typography variant="body2" color="text.secondary">
{t("Loading BOM Detail...")}
</Typography>
)}

{detail && (
<Stack spacing={2}>
<Typography variant="subtitle1">
{detail.itemCode} {detail.itemName}({t("Output Quantity")} {detail.outputQty}{" "}
{detail.outputQtyUom})
</Typography>

{/* 材料列表 */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom>
材料 (Bom Material)
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell> {t("Item Code")}</TableCell>
<TableCell> {t("Item Name")}</TableCell>
<TableCell align="right"> {t("Base Qty")}</TableCell>
<TableCell> {t("Base UOM")}</TableCell>
<TableCell align="right"> {t("Stock Qty")}</TableCell>
<TableCell> {t("Stock UOM")}</TableCell>
<TableCell align="right"> {t("Sales Qty")}</TableCell>
<TableCell> {t("Sales UOM")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{detail.materials.map((m, i) => (
<TableRow key={i}>
<TableCell>{m.itemCode}</TableCell>
<TableCell>{m.itemName}</TableCell>
<TableCell align="right">{m.baseQty}</TableCell>
<TableCell>{m.baseUom}</TableCell>
<TableCell align="right">{m.stockQty}</TableCell>
<TableCell>{m.stockUom}</TableCell>
<TableCell align="right">{m.salesQty}</TableCell>
<TableCell>{m.salesUom}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>

{/* 製程 + 設備列表 */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom>
{t("Process & Equipment")}
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell> {t("Sequence")}</TableCell>
<TableCell> {t("Process Name")}</TableCell>
<TableCell> {t("Process Description")}</TableCell>
<TableCell> {t("Equipment Name")}</TableCell>
<TableCell align="right"> {t("Duration (Minutes)")}</TableCell>
<TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell>
<TableCell align="right"> {t("Post Prod Time (Minutes)")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{detail.processes.map((p, i) => (
<TableRow key={i}>
<TableCell>{p.seqNo}</TableCell>
<TableCell>{p.processName}</TableCell>
<TableCell>{p.processDescription}</TableCell>
<TableCell>{p.equipmentName}</TableCell>
<TableCell align="right">
{p.durationInMinute}
</TableCell>
<TableCell align="right">
{p.prepTimeInMinute}
</TableCell>
<TableCell align="right">
{p.postProdTimeInMinute}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
</Stack>
)}
</Stack>
);
};

export default ImportBomDetailTab;

+ 75
- 42
src/components/ImportBom/ImportBomResultForm.tsx Просмотреть файл

@@ -16,28 +16,31 @@ import CircularProgress from "@mui/material/CircularProgress";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import type { BomFormatFileGroup } from "@/app/api/bom"; import type { BomFormatFileGroup } from "@/app/api/bom";
import { importBom } from "@/app/api/bom/client";
type CorrectItem = { fileName: string; isAlsoWip: boolean };
import { importBom, downloadBomFormatIssueLog } from "@/app/api/bom/client";
import { useTranslation } from "react-i18next";
type CorrectItem = { fileName: string; isAlsoWip: boolean; isDrink: boolean };


type Props = { type Props = {
batchId: string; batchId: string;
correctFileNames: string[]; correctFileNames: string[];
failList: BomFormatFileGroup[]; failList: BomFormatFileGroup[];
uploadedCount: number; uploadedCount: number;
issueLogFileId?: string; // 新增
onBack?: () => void; onBack?: () => void;
};

export default function ImportBomResultForm({
};
export default function ImportBomResultForm({
batchId, batchId,
correctFileNames, correctFileNames,
failList, failList,
uploadedCount, uploadedCount,
issueLogFileId,
onBack, onBack,
}: Props) {
}: Props) {
console.log("issueLogFileId from props", issueLogFileId);
const { t } = useTranslation("common");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [items, setItems] = useState<CorrectItem[]>(() => const [items, setItems] = useState<CorrectItem[]>(() =>
correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false }))
correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false, isDrink: false }))
); );
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [successMsg, setSuccessMsg] = useState<string | null>(null); const [successMsg, setSuccessMsg] = useState<string | null>(null);
@@ -57,19 +60,36 @@ export default function ImportBomResultForm({
) )
); );
}; };

const handleToggleDrink = (fileName: string) => {
setItems((prev) =>
prev.map((x) =>
x.fileName === fileName
? { ...x, isDrink: !x.isDrink }
: x
)
);
};
const handleDownloadIssueLog = async () => {
const blob = await downloadBomFormatIssueLog(batchId, issueLogFileId!);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
};
const handleConfirm = async () => { const handleConfirm = async () => {
setSubmitting(true); setSubmitting(true);
setSuccessMsg(null); setSuccessMsg(null);
try { try {
const blob = await importBom(batchId, items); const blob = await importBom(batchId, items);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
setSuccessMsg("匯入完成,已下載 issue log。");
//const url = URL.createObjectURL(blob);
//const a = document.createElement("a");
//a.href = url;
//a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`;
//a.click();
//URL.revokeObjectURL(url);
setSuccessMsg("匯入完成");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setSuccessMsg("匯入失敗,請查看主控台。"); setSuccessMsg("匯入失敗,請查看主控台。");
@@ -111,7 +131,7 @@ export default function ImportBomResultForm({
> >
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>
正確 BOM 列表(可匯入)
{t("Correct BOM List (Can Import)")}
</Typography> </Typography>
<TextField <TextField
size="small" size="small"
@@ -128,38 +148,44 @@ export default function ImportBomResultForm({
sx={{ mb: 2, width: "100%" }} sx={{ mb: 2, width: "100%" }}
/> />
<Stack spacing={0.5}> <Stack spacing={0.5}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 0.5, pb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("WIP")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("Drink")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flex: 1 }}>{t("File Name")}</Typography>
</Stack>
{filteredCorrect.map((item) => ( {filteredCorrect.map((item) => (
<Stack
key={item.fileName}
direction="row"
alignItems="center"
spacing={1}
>
<Checkbox
checked={item.isAlsoWip}
onChange={() =>
handleToggleWip(item.fileName)
}
size="small"
/>
<Typography
variant="body2"
sx={{ flex: 1 }}
noWrap
>
{item.fileName}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.isAlsoWip ? "同時建 WIP" : ""}
</Typography>
</Stack>
<Stack
key={item.fileName}
direction="row"
alignItems="center"
spacing={1}
>
<Checkbox
checked={item.isAlsoWip}
onChange={() => handleToggleWip(item.fileName)}
size="small"
/>
<Checkbox
checked={item.isDrink}
onChange={() => handleToggleDrink(item.fileName)}
size="small"
/>
<Typography
variant="body2"
sx={{ flex: 1 }}
noWrap
>
{item.fileName}
</Typography>
</Stack>
))} ))}
</Stack> </Stack>
</Paper> </Paper>


<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>
失敗 BOM 列表
{t("Issue BOM List")}
</Typography> </Typography>
{failList.length === 0 ? ( {failList.length === 0 ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -201,6 +227,13 @@ export default function ImportBomResultForm({
> >
確認匯入 確認匯入
</Button> </Button>
<Button
variant="outlined"
onClick={handleDownloadIssueLog}
disabled={!issueLogFileId}
>
下載檢查結果 Excel
</Button>
{submitting && <CircularProgress size={24} />} {submitting && <CircularProgress size={24} />}
{successMsg && ( {successMsg && (
<Typography variant="body2" color="primary"> <Typography variant="body2" color="primary">


+ 45
- 10
src/components/ImportBom/ImportBomUpload.tsx Просмотреть файл

@@ -10,7 +10,7 @@ import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent"; import CardContent from "@mui/material/CardContent";
import Alert from "@mui/material/Alert"; import Alert from "@mui/material/Alert";
import { uploadBomFiles } from "@/app/api/bom/client"; import { uploadBomFiles } from "@/app/api/bom/client";
import { checkBomFormat } from "@/app/api/bom/client";
import { checkBomFormat, getBomFormatProgress ,BomExcelCheckProgress} from "@/app/api/bom/client";
import type { BomFormatCheckResponse } from "@/app/api/bom"; import type { BomFormatCheckResponse } from "@/app/api/bom";


type Props = { type Props = {
@@ -18,6 +18,7 @@ type Props = {
batchId: string, batchId: string,
results: BomFormatCheckResponse, results: BomFormatCheckResponse,
uploadedCount: number uploadedCount: number
) => void; ) => void;
}; };


@@ -39,7 +40,8 @@ export default function ImportBomUpload({ onSuccess }: Props) {
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);

const [progress, setProgress] = useState<BomExcelCheckProgress | null>(null);
const [startTime, setStartTime] = useState<number | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files; const selected = e.target.files;
if (!selected?.length) return; if (!selected?.length) return;
@@ -52,21 +54,40 @@ export default function ImportBomUpload({ onSuccess }: Props) {


const handleUploadAndCheck = async () => { const handleUploadAndCheck = async () => {
if (files.length === 0) { if (files.length === 0) {
setError("請至少選擇一個 .xlsx 檔案");
return;
setError("請至少選擇一個 .xlsx 檔案");
return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
setProgress(null);
let timer: number | undefined;
try { try {
const { batchId } = await uploadBomFiles(files);
const results = await checkBomFormat(batchId);
onSuccess(batchId, results, files.length);
const { batchId } = await uploadBomFiles(files);
setStartTime(Date.now());
// 啟動輪詢:每 1 秒打一次 progress API
timer = window.setInterval(async () => {
try {
const p = await getBomFormatProgress(batchId);
setProgress(p);
} catch (e) {
// 進度查詢失敗可以暫時忽略或寫 console
console.warn("load bom progress failed", e);
}
}, 1000);
const results = await checkBomFormat(batchId); // 這裡會等後端整個檢查完
onSuccess(batchId, results, files.length);
} catch (err: unknown) { } catch (err: unknown) {
setError(getErrorMessage(err));
setError(getErrorMessage(err));
} finally { } finally {
setLoading(false);
setLoading(false);
if (timer !== undefined) {
window.clearInterval(timer);
}
} }
};
};


return ( return (
<Card variant="outlined" sx={{ maxWidth: 560 }}> <Card variant="outlined" sx={{ maxWidth: 560 }}>
@@ -114,6 +135,20 @@ export default function ImportBomUpload({ onSuccess }: Props) {
{loading ? "上傳與檢查中…" : "上傳並檢查"} {loading ? "上傳與檢查中…" : "上傳並檢查"}
</Button> </Button>
{loading && <CircularProgress size={24} />} {loading && <CircularProgress size={24} />}
{loading && progress && (
<Typography variant="body2" color="text.secondary">
已檢查 {progress.processedFiles} / {progress.totalFiles} 個檔案
{progress.currentFileName && `,目前:${progress.currentFileName}`}
{startTime && progress.processedFiles > 0 && progress.totalFiles > 0 && (() => {
const elapsedMs = Date.now() - startTime;
const avgPerFile = elapsedMs / progress.processedFiles;
const remainingFiles = progress.totalFiles - progress.processedFiles;
const remainingMs = Math.max(0, remainingFiles * avgPerFile);
const remainingSec = Math.round(remainingMs / 1000);
return `,約還需 ${remainingSec} 秒`;
})()}
</Typography>
)}
</Box> </Box>
{error && ( {error && (
<Alert severity="error" onClose={() => setError(null)}> <Alert severity="error" onClose={() => setError(null)}>


+ 65
- 35
src/components/ImportBom/ImportBomWrapper.tsx Просмотреть файл

@@ -2,43 +2,73 @@


import React, { useState } from "react"; import React, { useState } from "react";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Box from "@mui/material/Box";
import ImportBomUpload from "./ImportBomUpload"; import ImportBomUpload from "./ImportBomUpload";
import ImportBomResultForm from "./ImportBomResultForm"; import ImportBomResultForm from "./ImportBomResultForm";
import ImportBomDetailTab from "./ImportBomDetailTab";
import type { BomFormatCheckResponse } from "@/app/api/bom"; import type { BomFormatCheckResponse } from "@/app/api/bom";


export default function ImportBomWrapper() { export default function ImportBomWrapper() {
const [batchId, setBatchId] = useState<string | null>(null);
const [formatResults, setFormatResults] = useState<BomFormatCheckResponse | null>(null);
const [uploadedCount, setUploadedCount] = useState<number>(0);

const handleUploadSuccess = (
id: string,
results: BomFormatCheckResponse,
count: number
) => {
setBatchId(id);
setFormatResults(results);
setUploadedCount(count);
};

const handleBack = () => {
setBatchId(null);
setFormatResults(null);
};

return (
<Stack spacing={3}>
{formatResults === null ? (
<ImportBomUpload onSuccess={handleUploadSuccess} />
) : batchId ? (
<ImportBomResultForm
batchId={batchId}
correctFileNames={formatResults.correctFileNames}
failList={formatResults.failList}
uploadedCount={uploadedCount}
onBack={handleBack}
/>
) : null}
</Stack>
);
}
const [batchId, setBatchId] = useState<string | null>(null);
const [formatResults, setFormatResults] =
useState<BomFormatCheckResponse | null>(null);
const [uploadedCount, setUploadedCount] = useState<number>(0);
const [currentTab, setCurrentTab] = useState<number>(0);

const handleUploadSuccess = (
id: string,
results: BomFormatCheckResponse,
count: number
) => {
setBatchId(id);
setFormatResults(results);
setUploadedCount(count);
};

const handleBack = () => {
setBatchId(null);
setFormatResults(null);
};

const handleTabChange = (_e: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
};

return (
<Stack spacing={3}>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={currentTab} onChange={handleTabChange}>
<Tab label="匯入 BOM" />
<Tab label="BOM 明細" />
</Tabs>
</Box>

{/* Tab 0: 原本匯入流程 */}
{currentTab === 0 && (
<Box sx={{ pt: 2 }}>
{formatResults === null ? (
<ImportBomUpload onSuccess={handleUploadSuccess} />
) : batchId ? (
<ImportBomResultForm
batchId={batchId}
correctFileNames={formatResults.correctFileNames}
failList={formatResults.failList}
uploadedCount={uploadedCount}
issueLogFileId={formatResults.issueLogFileId}
onBack={handleBack}
/>
) : null}
</Box>
)}

{/* Tab 1: BOM 詳細資料 */}
{currentTab === 1 && (
<Box sx={{ pt: 2 }}>
<ImportBomDetailTab />
</Box>
)}
</Stack>
);
}

+ 3
- 1
src/components/QcCategorySave/QcCategoryDetails.tsx Просмотреть файл

@@ -46,7 +46,9 @@ const QcCategoryDetails = () => {
<Grid item xs={12}> <Grid item xs={12}>
<TextField <TextField
label={t("Description")} label={t("Description")}
// multiline
multiline
minRows={1}
maxRows={5}
fullWidth fullWidth
{...register("description", { {...register("description", {
maxLength: 100, maxLength: 100,


+ 146
- 13
src/components/QcItemAll/Tab0ItemQcCategoryMapping.tsx Просмотреть файл

@@ -32,6 +32,8 @@ import {
fetchQcCategoriesForAll, fetchQcCategoriesForAll,
fetchItemsForAll, fetchItemsForAll,
getItemByCode, getItemByCode,
getCategoryType,
updateCategoryType,
} from "@/app/api/settings/qcItemAll/actions"; } from "@/app/api/settings/qcItemAll/actions";
import { import {
QcCategoryResult, QcCategoryResult,
@@ -62,7 +64,8 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
const [validatingItemCode, setValidatingItemCode] = useState<boolean>(false); const [validatingItemCode, setValidatingItemCode] = useState<boolean>(false);
const [selectedType, setSelectedType] = useState<string>("IQC"); const [selectedType, setSelectedType] = useState<string>("IQC");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

const [categoryType, setCategoryType] = useState<string>("IQC");
const [savingCategoryType, setSavingCategoryType] = useState(false);
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
@@ -87,8 +90,12 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {


const handleViewMappings = useCallback(async (category: QcCategoryResult) => { const handleViewMappings = useCallback(async (category: QcCategoryResult) => {
setSelectedCategory(category); setSelectedCategory(category);
const mappingData = await getItemQcCategoryMappings(category.id);
const [mappingData, typeFromApi] = await Promise.all([
getItemQcCategoryMappings(category.id),
getCategoryType(category.id),
]);
setMappings(mappingData); setMappings(mappingData);
setCategoryType(typeFromApi ?? "IQC"); // 方案 A: no mappings -> default IQC
setOpenDialog(true); setOpenDialog(true);
}, []); }, []);


@@ -97,8 +104,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
setItemCode(""); setItemCode("");
setValidatedItem(null); setValidatedItem(null);
setItemCodeError(""); setItemCodeError("");
setSelectedType(categoryType);
setOpenAddDialog(true); setOpenAddDialog(true);
}, [selectedCategory]);
}, [selectedCategory, categoryType]);
const handleItemCodeChange = useCallback(async (code: string) => { const handleItemCodeChange = useCallback(async (code: string) => {
setItemCode(code); setItemCode(code);
@@ -108,7 +116,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
if (!code || code.trim() === "") { if (!code || code.trim() === "") {
return; return;
} }
if (code.trim().length !== 6) {
return;
}
setValidatingItemCode(true); setValidatingItemCode(true);
try { try {
const item = await getItemByCode(code.trim()); const item = await getItemByCode(code.trim());
@@ -136,6 +146,7 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
validatedItem.id as number, validatedItem.id as number,
selectedCategory.id, selectedCategory.id,
selectedType selectedType
//categoryType
); );
// Close add dialog first // Close add dialog first
setOpenAddDialog(false); setOpenAddDialog(false);
@@ -148,8 +159,40 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
// Show success message after closing dialogs // Show success message after closing dialogs
await successDialog(t("Submit Success"), t); await successDialog(t("Submit Success"), t);
// Keep the view dialog open to show updated data // Keep the view dialog open to show updated data
} catch (error) {
errorDialogWithContent(t("Submit Error"), String(error), t);
} catch (error: unknown) {
let message: string;
if (error && typeof error === "object" && "message" in error) {
message = String((error as { message?: string }).message);
} else {
message = String(error);
}
// 嘗試從 message 裡解析出後端 FailureRes.error
try {
const jsonStart = message.indexOf("{");
if (jsonStart >= 0) {
const jsonPart = message.slice(jsonStart);
const parsed = JSON.parse(jsonPart);
if (parsed.error) {
message = parsed.error;
}
}
} catch {
// 解析失敗就維持原本的 message
}
let displayMessage = message;
if (displayMessage.includes("already has type") && displayMessage.includes("linked to QcCategory")) {
const match = displayMessage.match(/type "([^"]+)" linked to QcCategory[:\s]+(.+?)(?:\.|One item)/);
const type = match?.[1] ?? "";
const categoryName = match?.[2]?.trim() ?? "";
displayMessage = t("Item already has type \"{{type}}\" in QcCategory \"{{category}}\". One item can only have each type in one QcCategory.", {
type,
category: categoryName,
});
}
errorDialogWithContent(t("Submit Error"), displayMessage || t("Submit Error"), t);
} }
}, t); }, t);
}, [selectedCategory, validatedItem, selectedType, t]); }, [selectedCategory, validatedItem, selectedType, t]);
@@ -174,8 +217,18 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
[selectedCategory, t] [selectedCategory, t]
); );


const typeOptions = ["IQC", "IPQC", "OQC", "FQC"];

const typeOptions = ["IQC", "IPQC", "EPQC"];
function formatTypeDisplay(value: unknown): string {
if (value == null) return "null";
if (typeof value === "string") return value;
if (typeof value === "object" && value !== null && "type" in value) {
const v = (value as { type?: unknown }).type;
if (typeof v === "string") return v;
if (v != null && typeof v === "object") return "null"; // 避免 [object Object]
return "null";
}
return "null";
}
const searchCriteria: Criterion<SearchParamNames>[] = useMemo( const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [ () => [
{ label: t("Code"), paramName: "code", type: "text" }, { label: t("Code"), paramName: "code", type: "text" },
@@ -196,6 +249,21 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
() => [ () => [
{ name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") }, { name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") },
{ name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") }, { name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") },
{
name: "type",
label: t("Type"),
sx: columnWidthSx("10%"),
renderCell: (row) => {
const t = row.type;
if (t == null) return " "; // 原来是 "null"
if (typeof t === "string") return t;
if (typeof t === "object" && t !== null && "type" in t) {
const v = (t as { type?: unknown }).type;
return typeof v === "string" ? v : " "; // 原来是 "null"
}
return " "; // 原来是 "null"
},
},
{ {
name: "id", name: "id",
label: t("Actions"), label: t("Actions"),
@@ -237,13 +305,73 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
/> />


{/* View Mappings Dialog */} {/* View Mappings Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}>
<DialogTitle> <DialogTitle>
{t("Mapping Details")} - {selectedCategory?.name} {t("Mapping Details")} - {selectedCategory?.name}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 1 }}>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={2}
sx={{ mb: 1, minHeight: 40 }}
>
<Typography
variant="body2"
sx={{ display: "flex", alignItems: "center", minHeight: 40 }}
>
{t("Category Type")}
</Typography>
<TextField
select
size="small"
sx={{ minWidth: 120 }}
value={categoryType}
onChange={(e) => setCategoryType(e.target.value)}
SelectProps={{ native: true }}
>
{typeOptions.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</TextField>
<Button
variant="outlined"
size="small"
disabled={savingCategoryType}
onClick={async () => {
if (!selectedCategory) return;
setSavingCategoryType(true);
try {
await updateCategoryType(selectedCategory.id, categoryType);
setQcCategories(prev =>
prev.map(cat =>
cat.id === selectedCategory.id ? { ...cat, type: categoryType } : cat
)
);
setFilteredQcCategories(prev =>
prev.map(cat =>
cat.id === selectedCategory.id ? { ...cat, type: categoryType } : cat
)
);
// 2) 同步 selectedCategory,讓 Dialog 標題旁邊的資料也一致
setSelectedCategory(prev =>
prev && prev.id === selectedCategory.id ? { ...prev, type: categoryType } : prev
);
await successDialog(t("Submit Success"), t);
const mappingData = await getItemQcCategoryMappings(selectedCategory.id);
setMappings(mappingData);
} catch (e) {
errorDialogWithContent(t("Submit Error"), String(e), t);
} finally {
setSavingCategoryType(false);
}
}}
>
{t("Save")}
</Button>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button <Button
variant="contained" variant="contained"
startIcon={<Add />} startIcon={<Add />}
@@ -252,6 +380,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
{t("Add Mapping")} {t("Add Mapping")}
</Button> </Button>
</Box> </Box>
</Stack>
<Stack spacing={2} sx={{ mt: 1 }}>
<TableContainer> <TableContainer>
<Table> <Table>
<TableHead> <TableHead>
@@ -274,7 +405,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
<TableRow key={mapping.id}> <TableRow key={mapping.id}>
<TableCell>{mapping.itemCode}</TableCell> <TableCell>{mapping.itemCode}</TableCell>
<TableCell>{mapping.itemName}</TableCell> <TableCell>{mapping.itemName}</TableCell>
<TableCell>{mapping.type}</TableCell>
<TableCell>
{formatTypeDisplay(mapping.type)}
</TableCell>
<TableCell> <TableCell>
<IconButton <IconButton
color="error" color="error"
@@ -298,7 +431,7 @@ const Tab0ItemQcCategoryMapping: React.FC = () => {
</Dialog> </Dialog>


{/* Add Mapping Dialog */} {/* Add Mapping Dialog */}
<Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth>
<Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}>
<DialogTitle>{t("Add Mapping")}</DialogTitle> <DialogTitle>{t("Add Mapping")}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 2 }}> <Stack spacing={2} sx={{ mt: 2 }}>


+ 3
- 2
src/components/QcItemAll/Tab1QcCategoryQcItemMapping.tsx Просмотреть файл

@@ -167,6 +167,7 @@ const Tab1QcCategoryQcItemMapping: React.FC = () => {
() => [ () => [
{ name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") }, { name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") },
{ name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") }, { name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") },
{ name: "type", label: t("Type"), sx: columnWidthSx("10%") },
{ {
name: "id", name: "id",
label: t("Actions"), label: t("Actions"),
@@ -200,7 +201,7 @@ const Tab1QcCategoryQcItemMapping: React.FC = () => {
/> />


{/* View Mappings Dialog */} {/* View Mappings Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}>
<DialogTitle> <DialogTitle>
{t("Association Details")} - {selectedCategory?.name} {t("Association Details")} - {selectedCategory?.name}
</DialogTitle> </DialogTitle>
@@ -263,7 +264,7 @@ const Tab1QcCategoryQcItemMapping: React.FC = () => {
</Dialog> </Dialog>


{/* Add Mapping Dialog */} {/* Add Mapping Dialog */}
<Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth>
<Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}>
<DialogTitle>{t("Add Association")}</DialogTitle> <DialogTitle>{t("Add Association")}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 2 }}> <Stack spacing={2} sx={{ mt: 2 }}>


+ 2
- 1
src/components/QcItemAll/Tab2QcCategoryManagement.tsx Просмотреть файл

@@ -158,6 +158,7 @@ const Tab2QcCategoryManagement: React.FC = () => {
}, },
{ name: "code", label: t("Code"), sx: columnWidthSx("15%") }, { name: "code", label: t("Code"), sx: columnWidthSx("15%") },
{ name: "name", label: t("Name"), sx: columnWidthSx("30%") }, { name: "name", label: t("Name"), sx: columnWidthSx("30%") },
{ name: "type", label: t("Type"), sx: columnWidthSx("10%") },
{ {
name: "id", name: "id",
label: t("Delete"), label: t("Delete"),
@@ -200,7 +201,7 @@ const Tab2QcCategoryManagement: React.FC = () => {
/> />


{/* Add/Edit Dialog */} {/* Add/Edit Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}>
<DialogTitle> <DialogTitle>
{editingCategory ? t("Edit Qc Category") : t("Create Qc Category")} {editingCategory ? t("Edit Qc Category") : t("Create Qc Category")}
</DialogTitle> </DialogTitle>


+ 1
- 1
src/components/QcItemAll/Tab3QcItemManagement.tsx Просмотреть файл

@@ -200,7 +200,7 @@ const Tab3QcItemManagement: React.FC = () => {
/> />


{/* Add/Edit Dialog */} {/* Add/Edit Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}>
<DialogTitle> <DialogTitle>
{editingItem ? t("Edit Qc Item") : t("Create Qc Item")} {editingItem ? t("Edit Qc Item") : t("Create Qc Item")}
</DialogTitle> </DialogTitle>


Загрузка…
Отмена
Сохранить