| @@ -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; | |||||
| } | |||||
| @@ -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[]; | |||||
| } | |||||
| @@ -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"] }, | ||||
| @@ -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; | ||||
| @@ -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; | |||||
| @@ -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; | |||||
| @@ -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; | |||||
| @@ -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; | |||||
| @@ -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"> | ||||
| @@ -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)}> | ||||
| @@ -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> | |||||
| ); | |||||
| } | |||||
| @@ -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, | ||||
| @@ -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 }}> | ||||
| @@ -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 }}> | ||||
| @@ -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> | ||||
| @@ -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> | ||||