| @@ -6,6 +6,8 @@ import type { | |||
| BomFormatCheckResponse, | |||
| BomUploadResponse, | |||
| ImportBomItemPayload, | |||
| BomCombo, | |||
| BomDetailResponse, | |||
| } from "./index"; | |||
| export async function uploadBomFiles( | |||
| @@ -39,7 +41,19 @@ export async function checkBomFormat( | |||
| ); | |||
| 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( | |||
| batchId: string, | |||
| items: ImportBomItemPayload[] | |||
| @@ -60,3 +74,33 @@ export const fetchBomScoresClient = async (): Promise<BomScoreResult[]> => { | |||
| 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 { | |||
| correctFileNames: string[]; | |||
| failList: BomFormatFileGroup[]; | |||
| issueLogFileId: string; | |||
| } | |||
| export interface BomUploadResponse { | |||
| @@ -30,6 +31,7 @@ export interface BomUploadResponse { | |||
| export interface ImportBomItemPayload { | |||
| fileName: string; | |||
| isAlsoWip: boolean; | |||
| isDrink: boolean; | |||
| } | |||
| 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"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { serverFetchJson ,serverFetchWithNoContent} from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidatePath, revalidateTag } from "next/cache"; | |||
| import { | |||
| @@ -234,11 +234,29 @@ export const deleteQcItemWithValidation = async ( | |||
| // Server actions for fetching data (to be used in client components) | |||
| 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[]> => { | |||
| return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | |||
| next: { tags: ["items"] }, | |||
| @@ -9,7 +9,13 @@ export interface ItemQcCategoryMappingInfo { | |||
| qcCategoryName?: 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 { | |||
| id: 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 SearchIcon from "@mui/icons-material/Search"; | |||
| 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 = { | |||
| batchId: string; | |||
| correctFileNames: string[]; | |||
| failList: BomFormatFileGroup[]; | |||
| uploadedCount: number; | |||
| issueLogFileId?: string; // 新增 | |||
| onBack?: () => void; | |||
| }; | |||
| export default function ImportBomResultForm({ | |||
| }; | |||
| export default function ImportBomResultForm({ | |||
| batchId, | |||
| correctFileNames, | |||
| failList, | |||
| uploadedCount, | |||
| issueLogFileId, | |||
| onBack, | |||
| }: Props) { | |||
| }: Props) { | |||
| console.log("issueLogFileId from props", issueLogFileId); | |||
| const { t } = useTranslation("common"); | |||
| const [search, setSearch] = useState(""); | |||
| 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 [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 () => { | |||
| setSubmitting(true); | |||
| setSuccessMsg(null); | |||
| try { | |||
| 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) { | |||
| console.error(err); | |||
| setSuccessMsg("匯入失敗,請查看主控台。"); | |||
| @@ -111,7 +131,7 @@ export default function ImportBomResultForm({ | |||
| > | |||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||
| <Typography variant="subtitle1" gutterBottom> | |||
| 正確 BOM 列表(可匯入) | |||
| {t("Correct BOM List (Can Import)")} | |||
| </Typography> | |||
| <TextField | |||
| size="small" | |||
| @@ -128,38 +148,44 @@ export default function ImportBomResultForm({ | |||
| sx={{ mb: 2, width: "100%" }} | |||
| /> | |||
| <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) => ( | |||
| <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> | |||
| </Paper> | |||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||
| <Typography variant="subtitle1" gutterBottom> | |||
| 失敗 BOM 列表 | |||
| {t("Issue BOM List")} | |||
| </Typography> | |||
| {failList.length === 0 ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| @@ -201,6 +227,13 @@ export default function ImportBomResultForm({ | |||
| > | |||
| 確認匯入 | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={handleDownloadIssueLog} | |||
| disabled={!issueLogFileId} | |||
| > | |||
| 下載檢查結果 Excel | |||
| </Button> | |||
| {submitting && <CircularProgress size={24} />} | |||
| {successMsg && ( | |||
| <Typography variant="body2" color="primary"> | |||
| @@ -10,7 +10,7 @@ import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Alert from "@mui/material/Alert"; | |||
| 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"; | |||
| type Props = { | |||
| @@ -18,6 +18,7 @@ type Props = { | |||
| batchId: string, | |||
| results: BomFormatCheckResponse, | |||
| uploadedCount: number | |||
| ) => void; | |||
| }; | |||
| @@ -39,7 +40,8 @@ export default function ImportBomUpload({ onSuccess }: Props) { | |||
| const [files, setFiles] = useState<File[]>([]); | |||
| const [loading, setLoading] = useState(false); | |||
| 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 selected = e.target.files; | |||
| if (!selected?.length) return; | |||
| @@ -52,21 +54,40 @@ export default function ImportBomUpload({ onSuccess }: Props) { | |||
| const handleUploadAndCheck = async () => { | |||
| if (files.length === 0) { | |||
| setError("請至少選擇一個 .xlsx 檔案"); | |||
| return; | |||
| setError("請至少選擇一個 .xlsx 檔案"); | |||
| return; | |||
| } | |||
| setLoading(true); | |||
| setError(null); | |||
| setProgress(null); | |||
| let timer: number | undefined; | |||
| 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) { | |||
| setError(getErrorMessage(err)); | |||
| setError(getErrorMessage(err)); | |||
| } finally { | |||
| setLoading(false); | |||
| setLoading(false); | |||
| if (timer !== undefined) { | |||
| window.clearInterval(timer); | |||
| } | |||
| } | |||
| }; | |||
| }; | |||
| return ( | |||
| <Card variant="outlined" sx={{ maxWidth: 560 }}> | |||
| @@ -114,6 +135,20 @@ export default function ImportBomUpload({ onSuccess }: Props) { | |||
| {loading ? "上傳與檢查中…" : "上傳並檢查"} | |||
| </Button> | |||
| {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> | |||
| {error && ( | |||
| <Alert severity="error" onClose={() => setError(null)}> | |||
| @@ -2,43 +2,73 @@ | |||
| import React, { useState } from "react"; | |||
| 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 ImportBomResultForm from "./ImportBomResultForm"; | |||
| import ImportBomDetailTab from "./ImportBomDetailTab"; | |||
| import type { BomFormatCheckResponse } from "@/app/api/bom"; | |||
| 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}> | |||
| <TextField | |||
| label={t("Description")} | |||
| // multiline | |||
| multiline | |||
| minRows={1} | |||
| maxRows={5} | |||
| fullWidth | |||
| {...register("description", { | |||
| maxLength: 100, | |||
| @@ -32,6 +32,8 @@ import { | |||
| fetchQcCategoriesForAll, | |||
| fetchItemsForAll, | |||
| getItemByCode, | |||
| getCategoryType, | |||
| updateCategoryType, | |||
| } from "@/app/api/settings/qcItemAll/actions"; | |||
| import { | |||
| QcCategoryResult, | |||
| @@ -62,7 +64,8 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| const [validatingItemCode, setValidatingItemCode] = useState<boolean>(false); | |||
| const [selectedType, setSelectedType] = useState<string>("IQC"); | |||
| const [loading, setLoading] = useState(true); | |||
| const [categoryType, setCategoryType] = useState<string>("IQC"); | |||
| const [savingCategoryType, setSavingCategoryType] = useState(false); | |||
| useEffect(() => { | |||
| const loadData = async () => { | |||
| setLoading(true); | |||
| @@ -87,8 +90,12 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| const handleViewMappings = useCallback(async (category: QcCategoryResult) => { | |||
| setSelectedCategory(category); | |||
| const mappingData = await getItemQcCategoryMappings(category.id); | |||
| const [mappingData, typeFromApi] = await Promise.all([ | |||
| getItemQcCategoryMappings(category.id), | |||
| getCategoryType(category.id), | |||
| ]); | |||
| setMappings(mappingData); | |||
| setCategoryType(typeFromApi ?? "IQC"); // 方案 A: no mappings -> default IQC | |||
| setOpenDialog(true); | |||
| }, []); | |||
| @@ -97,8 +104,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| setItemCode(""); | |||
| setValidatedItem(null); | |||
| setItemCodeError(""); | |||
| setSelectedType(categoryType); | |||
| setOpenAddDialog(true); | |||
| }, [selectedCategory]); | |||
| }, [selectedCategory, categoryType]); | |||
| const handleItemCodeChange = useCallback(async (code: string) => { | |||
| setItemCode(code); | |||
| @@ -108,7 +116,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| if (!code || code.trim() === "") { | |||
| return; | |||
| } | |||
| if (code.trim().length !== 6) { | |||
| return; | |||
| } | |||
| setValidatingItemCode(true); | |||
| try { | |||
| const item = await getItemByCode(code.trim()); | |||
| @@ -136,6 +146,7 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| validatedItem.id as number, | |||
| selectedCategory.id, | |||
| selectedType | |||
| //categoryType | |||
| ); | |||
| // Close add dialog first | |||
| setOpenAddDialog(false); | |||
| @@ -148,8 +159,40 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| // Show success message after closing dialogs | |||
| await successDialog(t("Submit Success"), t); | |||
| // 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); | |||
| }, [selectedCategory, validatedItem, selectedType, t]); | |||
| @@ -174,8 +217,18 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| [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( | |||
| () => [ | |||
| { 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: "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", | |||
| label: t("Actions"), | |||
| @@ -237,13 +305,73 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| /> | |||
| {/* 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> | |||
| {t("Mapping Details")} - {selectedCategory?.name} | |||
| </DialogTitle> | |||
| <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 | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| @@ -252,6 +380,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| {t("Add Mapping")} | |||
| </Button> | |||
| </Box> | |||
| </Stack> | |||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||
| <TableContainer> | |||
| <Table> | |||
| <TableHead> | |||
| @@ -274,7 +405,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| <TableRow key={mapping.id}> | |||
| <TableCell>{mapping.itemCode}</TableCell> | |||
| <TableCell>{mapping.itemName}</TableCell> | |||
| <TableCell>{mapping.type}</TableCell> | |||
| <TableCell> | |||
| {formatTypeDisplay(mapping.type)} | |||
| </TableCell> | |||
| <TableCell> | |||
| <IconButton | |||
| color="error" | |||
| @@ -298,7 +431,7 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| </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> | |||
| <DialogContent> | |||
| <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: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") }, | |||
| { name: "type", label: t("Type"), sx: columnWidthSx("10%") }, | |||
| { | |||
| name: "id", | |||
| label: t("Actions"), | |||
| @@ -200,7 +201,7 @@ const Tab1QcCategoryQcItemMapping: React.FC = () => { | |||
| /> | |||
| {/* 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> | |||
| {t("Association Details")} - {selectedCategory?.name} | |||
| </DialogTitle> | |||
| @@ -263,7 +264,7 @@ const Tab1QcCategoryQcItemMapping: React.FC = () => { | |||
| </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> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ mt: 2 }}> | |||
| @@ -158,6 +158,7 @@ const Tab2QcCategoryManagement: React.FC = () => { | |||
| }, | |||
| { name: "code", label: t("Code"), sx: columnWidthSx("15%") }, | |||
| { name: "name", label: t("Name"), sx: columnWidthSx("30%") }, | |||
| { name: "type", label: t("Type"), sx: columnWidthSx("10%") }, | |||
| { | |||
| name: "id", | |||
| label: t("Delete"), | |||
| @@ -200,7 +201,7 @@ const Tab2QcCategoryManagement: React.FC = () => { | |||
| /> | |||
| {/* 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> | |||
| {editingCategory ? t("Edit Qc Category") : t("Create Qc Category")} | |||
| </DialogTitle> | |||
| @@ -200,7 +200,7 @@ const Tab3QcItemManagement: React.FC = () => { | |||
| /> | |||
| {/* 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> | |||
| {editingItem ? t("Edit Qc Item") : t("Create Qc Item")} | |||
| </DialogTitle> | |||