diff --git a/src/app/api/bom/client.ts b/src/app/api/bom/client.ts index 0af97fb..1dcf663 100644 --- a/src/app/api/bom/client.ts +++ b/src/app/api/bom/client.ts @@ -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 { + 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 => { return response.data; }; +export async function fetchBomComboClient(): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/bom/combo` + ); + return response.data; + } + + export async function fetchBomDetailClient(id: number): Promise { + const response = await axiosInstance.get( + `${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 { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/bom/import-bom/format-check/progress`, + { params: { batchId } } + ); + return response.data; + } \ No newline at end of file diff --git a/src/app/api/bom/index.ts b/src/app/api/bom/index.ts index 9e34f1a..1d57616 100644 --- a/src/app/api/bom/index.ts +++ b/src/app/api/bom/index.ts @@ -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[]; +} \ No newline at end of file diff --git a/src/app/api/settings/qcItemAll/actions.ts b/src/app/api/settings/qcItemAll/actions.ts index e3a8dde..e6671d5 100644 --- a/src/app/api/settings/qcItemAll/actions.ts +++ b/src/app/api/settings/qcItemAll/actions.ts @@ -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 => { - return serverFetchJson(`${BASE_API_URL}/qcCategories`, { - next: { tags: ["qcCategories"] }, - }); + return serverFetchJson( + `${BASE_API_URL}/qcItemAll/categoriesWithItemCountsAndType`, + { next: { tags: ["qcItemAll", "qcCategories"] } } + ); +}; +type CategoryTypeResponse = { type: string | null }; +export const getCategoryType = async (qcCategoryId: number): Promise => { + const res = await serverFetchJson( + `${BASE_API_URL}/qcItemAll/categoryType/${qcCategoryId}` + ); + return res.type ?? null; }; +export const updateCategoryType = async ( + qcCategoryId: number, + type: string +): Promise => { + await serverFetchWithNoContent( + `${BASE_API_URL}/qcItemAll/categoryType?qcCategoryId=${qcCategoryId}&type=${encodeURIComponent(type)}`, + { method: "PUT" } + ); + revalidateTag("qcItemAll"); +}; export const fetchItemsForAll = async (): Promise => { return serverFetchJson(`${BASE_API_URL}/items`, { next: { tags: ["items"] }, diff --git a/src/app/api/settings/qcItemAll/index.ts b/src/app/api/settings/qcItemAll/index.ts index e228af6..44b23d1 100644 --- a/src/app/api/settings/qcItemAll/index.ts +++ b/src/app/api/settings/qcItemAll/index.ts @@ -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; diff --git a/src/components/ImportBom/EquipmentSearch.tsx b/src/components/ImportBom/EquipmentSearch.tsx deleted file mode 100644 index 82c4dd1..0000000 --- a/src/components/ImportBom/EquipmentSearch.tsx +++ /dev/null @@ -1,1039 +0,0 @@ -"use client"; - -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import SearchBox, { Criterion } from "../SearchBox"; -import { EquipmentResult } from "@/app/api/settings/equipment"; -import { useTranslation } from "react-i18next"; -import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; -import { EditNote } from "@mui/icons-material"; -import { useRouter } from "next/navigation"; -import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; -import axiosInstance from "@/app/(main)/axios/axiosInstance"; -import { arrayToDateTimeString } from "@/app/utils/formatUtil"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import CircularProgress from "@mui/material/CircularProgress"; -import TableRow from "@mui/material/TableRow"; -import TableCell from "@mui/material/TableCell"; -import Collapse from "@mui/material/Collapse"; -import Grid from "@mui/material/Grid"; -import DeleteIcon from "@mui/icons-material/Delete"; -import AddIcon from "@mui/icons-material/Add"; -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogTitle from "@mui/material/DialogTitle"; -import DialogContent from "@mui/material/DialogContent"; -import DialogContentText from "@mui/material/DialogContentText"; -import DialogActions from "@mui/material/DialogActions"; -import TextField from "@mui/material/TextField"; -import Autocomplete from "@mui/material/Autocomplete"; -import InputAdornment from "@mui/material/InputAdornment"; - -type Props = { - equipments: EquipmentResult[]; - tabIndex?: number; -}; -type SearchQuery = Partial>; -type SearchParamNames = keyof SearchQuery; - -const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { - const [filteredEquipments, setFilteredEquipments] = - useState([]); - const { t } = useTranslation("common"); - const router = useRouter(); - const [filterObjByTab, setFilterObjByTab] = useState>({ - 0: {}, - 1: {}, - }); - const [pagingControllerByTab, setPagingControllerByTab] = useState>({ - 0: { pageNum: 1, pageSize: 10 }, - 1: { pageNum: 1, pageSize: 10 }, - }); - const [totalCount, setTotalCount] = useState(0); - const [isLoading, setIsLoading] = useState(true); - const [isReady, setIsReady] = useState(false); - - const filterObj = filterObjByTab[tabIndex] || {}; - const pagingController = pagingControllerByTab[tabIndex] || { pageNum: 1, pageSize: 10 }; - - const [expandedRows, setExpandedRows] = useState>(new Set()); - const [equipmentDetailsMap, setEquipmentDetailsMap] = useState>(new Map()); - const [loadingDetailsMap, setLoadingDetailsMap] = useState>(new Map()); - - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [itemToDelete, setItemToDelete] = useState<{ id: string | number; equipmentId: string | number } | null>(null); - const [deleting, setDeleting] = useState(false); - - const [addDialogOpen, setAddDialogOpen] = useState(false); - const [equipmentList, setEquipmentList] = useState([]); - const [selectedDescription, setSelectedDescription] = useState(""); - const [selectedName, setSelectedName] = useState(""); - const [selectedEquipmentCode, setSelectedEquipmentCode] = useState(""); - const [equipmentCodePrefix, setEquipmentCodePrefix] = useState(""); - const [equipmentCodeNumber, setEquipmentCodeNumber] = useState(""); - const [isExistingCombination, setIsExistingCombination] = useState(false); - const [loadingEquipments, setLoadingEquipments] = useState(false); - const [saving, setSaving] = useState(false); - - useEffect(() => { - const checkReady = () => { - try { - const token = localStorage.getItem("accessToken"); - const hasAuthHeader = axiosInstance.defaults.headers?.common?.Authorization || - axiosInstance.defaults.headers?.Authorization; - - if (token && hasAuthHeader) { - setIsReady(true); - } else if (token) { - setTimeout(checkReady, 50); - } else { - setTimeout(checkReady, 100); - } - } catch (e) { - console.warn("localStorage unavailable", e); - } - }; - - const timer = setTimeout(checkReady, 100); - return () => clearTimeout(timer); - }, []); - - const displayDateTime = useCallback((dateValue: string | Date | number[] | null | undefined): string => { - if (!dateValue) return "-"; - - if (Array.isArray(dateValue)) { - return arrayToDateTimeString(dateValue); - } - - if (typeof dateValue === "string") { - return dateValue; - } - - return String(dateValue); - }, []); - - const searchCriteria: Criterion[] = useMemo(() => { - if (tabIndex === 1) { - return [ - { - label: "設備名稱/設備編號", - paramName: "equipmentCode", - type: "text" - }, - { - label: t("Repair and Maintenance Status"), - paramName: "repairAndMaintenanceStatus", - type: "select", - options: ["正常使用中", "正在維護中"] - }, - ]; - } - - return [ - { label: "設備編號", paramName: "code", type: "text" }, - ]; - }, [t, tabIndex]); - - const onMaintenanceEditClick = useCallback( - (equipment: EquipmentResult) => { - router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`); - }, - [router], - ); - - const onDeleteClick = useCallback( - (equipment: EquipmentResult) => {}, - [router], - ); - - const fetchEquipmentDetailsByEquipmentId = useCallback(async (equipmentId: string | number) => { - setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, true)); - try { - const response = await axiosInstance.get<{ - records: EquipmentResult[]; - total: number; - }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byEquipmentId/${equipmentId}`); - - if (response.status === 200) { - setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, response.data.records || [])); - } - } catch (error) { - console.error("Error fetching equipment details:", error); - setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, [])); - } finally { - setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, false)); - } - }, []); - - const handleDeleteClick = useCallback((detailId: string | number, equipmentId: string | number) => { - setItemToDelete({ id: detailId, equipmentId }); - setDeleteDialogOpen(true); - }, []); - - const handleDeleteConfirm = useCallback(async () => { - if (!itemToDelete) return; - - setDeleting(true); - try { - const response = await axiosInstance.delete( - `${NEXT_PUBLIC_API_URL}/EquipmentDetail/delete/${itemToDelete.id}` - ); - - if (response.status === 200 || response.status === 204) { - setEquipmentDetailsMap(prev => { - const newMap = new Map(prev); - const currentDetails = newMap.get(itemToDelete.equipmentId) || []; - const updatedDetails = currentDetails.filter(detail => detail.id !== itemToDelete.id); - newMap.set(itemToDelete.equipmentId, updatedDetails); - return newMap; - }); - } - } catch (error) { - console.error("Error deleting equipment detail:", error); - alert("刪除失敗,請稍後再試"); - } finally { - setDeleting(false); - setDeleteDialogOpen(false); - setItemToDelete(null); - } - }, [itemToDelete]); - - const handleDeleteCancel = useCallback(() => { - setDeleteDialogOpen(false); - setItemToDelete(null); - }, []); - - const fetchEquipmentList = useCallback(async () => { - setLoadingEquipments(true); - try { - const response = await axiosInstance.get<{ - records: EquipmentResult[]; - total: number; - }>(`${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, { - params: { - pageNum: 1, - pageSize: 1000, - }, - }); - - if (response.status === 200) { - setEquipmentList(response.data.records || []); - } - } catch (error) { - console.error("Error fetching equipment list:", error); - setEquipmentList([]); - } finally { - setLoadingEquipments(false); - } - }, []); - - const handleAddClick = useCallback(() => { - setAddDialogOpen(true); - fetchEquipmentList(); - }, [fetchEquipmentList]); - - const handleAddDialogClose = useCallback(() => { - setAddDialogOpen(false); - setSelectedDescription(""); - setSelectedName(""); - setSelectedEquipmentCode(""); - setEquipmentCodePrefix(""); - setEquipmentCodeNumber(""); - setIsExistingCombination(false); - }, []); - - const availableDescriptions = useMemo(() => { - const descriptions = equipmentList - .map((eq) => eq.description) - .filter((desc): desc is string => Boolean(desc)); - return Array.from(new Set(descriptions)); - }, [equipmentList]); - - const availableNames = useMemo(() => { - const names = equipmentList - .map((eq) => eq.name) - .filter((name): name is string => Boolean(name)); - return Array.from(new Set(names)); - }, [equipmentList]); - - useEffect(() => { - const checkAndGenerateEquipmentCode = async () => { - if (!selectedDescription || !selectedName) { - setIsExistingCombination(false); - setSelectedEquipmentCode(""); - return; - } - - const equipmentCode = `${selectedDescription}-${selectedName}`; - const existingEquipment = equipmentList.find((eq) => eq.code === equipmentCode); - - if (existingEquipment) { - setIsExistingCombination(true); - - try { - const existingDetailsResponse = await axiosInstance.get<{ - records: EquipmentResult[]; - total: number; - }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`); - - let newEquipmentCode = ""; - - if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) { - const equipmentCodePatterns = existingDetailsResponse.data.records - .map((detail) => { - if (!detail.equipmentCode) return null; - const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/); - if (match) { - const originalNumber = match[2]; - return { - prefix: match[1], - number: parseInt(match[2], 10), - paddingLength: originalNumber.length - }; - } - return null; - }) - .filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null); - - if (equipmentCodePatterns.length > 0) { - const prefix = equipmentCodePatterns[0].prefix; - const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number)); - const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength)); - const nextNumber = maxEquipmentCodeNumber + 1; - newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`; - } else { - newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; - } - } else { - newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; - } - - setSelectedEquipmentCode(newEquipmentCode); - } catch (error) { - console.error("Error checking existing equipment details:", error); - setIsExistingCombination(false); - setSelectedEquipmentCode(""); - } - } else { - setIsExistingCombination(false); - setSelectedEquipmentCode(""); - setEquipmentCodePrefix(""); - setEquipmentCodeNumber(""); - } - }; - - checkAndGenerateEquipmentCode(); - }, [selectedDescription, selectedName, equipmentList]); - - useEffect(() => { - const generateNumberForPrefix = async () => { - if (isExistingCombination || !equipmentCodePrefix) { - return; - } - - if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) { - setEquipmentCodeNumber(""); - setSelectedEquipmentCode(equipmentCodePrefix); - return; - } - - try { - const response = await axiosInstance.get<{ - records: EquipmentResult[]; - total: number; - }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, { - params: { - pageNum: 1, - pageSize: 1000, - }, - }); - - let maxNumber = 0; - let maxPaddingLength = 2; - - if (response.data.records && response.data.records.length > 0) { - const matchingCodes = response.data.records - .map((detail) => { - if (!detail.equipmentCode) return null; - const match = detail.equipmentCode.match(new RegExp(`^${equipmentCodePrefix}(\\d+)$`)); - if (match) { - const numberStr = match[1]; - return { - number: parseInt(numberStr, 10), - paddingLength: numberStr.length - }; - } - return null; - }) - .filter((item): item is { number: number; paddingLength: number } => item !== null); - - if (matchingCodes.length > 0) { - maxNumber = Math.max(...matchingCodes.map(c => c.number)); - maxPaddingLength = Math.max(...matchingCodes.map(c => c.paddingLength)); - } - } - - const nextNumber = maxNumber + 1; - const numberStr = String(nextNumber).padStart(maxPaddingLength, '0'); - setEquipmentCodeNumber(numberStr); - setSelectedEquipmentCode(`${equipmentCodePrefix}${numberStr}`); - } catch (error) { - console.error("Error generating equipment code number:", error); - setEquipmentCodeNumber(""); - setSelectedEquipmentCode(equipmentCodePrefix); - } - }; - - generateNumberForPrefix(); - }, [equipmentCodePrefix, isExistingCombination]); - - const handleToggleExpand = useCallback( - (id: string | number, code: string) => { - setExpandedRows(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) { - newSet.delete(id); - } else { - newSet.add(id); - if (!equipmentDetailsMap.has(id)) { - fetchEquipmentDetailsByEquipmentId(id); - } - } - return newSet; - }); - }, - [equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId] - ); - - const generalDataColumns = useMemo[]>( - () => [ - { - name: "code", - label: "設備編號", - renderCell: (item) => ( - - { - e.stopPropagation(); - handleToggleExpand(item.id, item.code); - }} - sx={{ padding: 0.5 }} - > - {expandedRows.has(item.id) ? ( - - ) : ( - - )} - - {item.code} - - ), - }, - ], - [t, handleToggleExpand, expandedRows], - ); - - const repairMaintenanceColumns = useMemo[]>( - () => [ - { - name: "id", - label: "編輯", - onClick: onMaintenanceEditClick, - buttonIcon: , - align: "left", - headerAlign: "left", - sx: { width: "60px", minWidth: "60px" }, - }, - { - name: "code", - label: "設備名稱", - align: "left", - headerAlign: "left", - sx: { width: "200px", minWidth: "200px" }, - }, - { - name: "equipmentCode", - label: "設備編號", - align: "left", - headerAlign: "left", - sx: { width: "150px", minWidth: "150px" }, - renderCell: (item) => { - return item.equipmentCode || "-"; - }, - }, - { - name: "repairAndMaintenanceStatus", - label: t("Repair and Maintenance Status"), - align: "left", - headerAlign: "left", - sx: { width: "150px", minWidth: "150px" }, - renderCell: (item) => { - const status = item.repairAndMaintenanceStatus; - if (status === 1 || status === true) { - return ( - - 正在維護中 - - ); - } else if (status === 0 || status === false) { - return ( - - 正常使用中 - - ); - } - return "-"; - }, - }, - { - name: "latestRepairAndMaintenanceDate", - label: t("Latest Repair and Maintenance Date"), - align: "left", - headerAlign: "left", - sx: { width: "200px", minWidth: "200px" }, - renderCell: (item) => displayDateTime(item.latestRepairAndMaintenanceDate), - }, - { - name: "lastRepairAndMaintenanceDate", - label: t("Last Repair and Maintenance Date"), - align: "left", - headerAlign: "left", - sx: { width: "200px", minWidth: "200px" }, - renderCell: (item) => displayDateTime(item.lastRepairAndMaintenanceDate), - }, - { - name: "repairAndMaintenanceRemarks", - label: t("Repair and Maintenance Remarks"), - align: "left", - headerAlign: "left", - sx: { width: "200px", minWidth: "200px" }, - }, - ], - [onMaintenanceEditClick, t, displayDateTime], - ); - - const columns = useMemo(() => { - return tabIndex === 1 ? repairMaintenanceColumns : generalDataColumns; - }, [tabIndex, repairMaintenanceColumns, generalDataColumns]); - - interface ApiResponse { - records: T[]; - total: number; - } - - const refetchData = useCallback( - async (filterObj: SearchQuery) => { - const token = localStorage.getItem("accessToken"); - const hasAuthHeader = axiosInstance.defaults.headers?.common?.Authorization || - axiosInstance.defaults.headers?.Authorization; - - if (!token || !hasAuthHeader) { - console.warn("Token or auth header not ready, skipping API call"); - setIsLoading(false); - return; - } - - setIsLoading(true); - - const transformedFilter: any = { ...filterObj }; - - if (tabIndex === 1 && transformedFilter.equipmentCode) { - transformedFilter.code = transformedFilter.equipmentCode; - } - - if (transformedFilter.repairAndMaintenanceStatus) { - if (transformedFilter.repairAndMaintenanceStatus === "正常使用中") { - transformedFilter.repairAndMaintenanceStatus = false; - } else if (transformedFilter.repairAndMaintenanceStatus === "正在維護中") { - transformedFilter.repairAndMaintenanceStatus = true; - } else if (transformedFilter.repairAndMaintenanceStatus === "All") { - delete transformedFilter.repairAndMaintenanceStatus; - } - } - - const params = { - pageNum: pagingController.pageNum, - pageSize: pagingController.pageSize, - ...transformedFilter, - }; - - try { - const endpoint = tabIndex === 1 - ? `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage` - : `${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`; - - const response = await axiosInstance.get>( - endpoint, - { params }, - ); - console.log("API Response:", response); - console.log("Records:", response.data.records); - console.log("Total:", response.data.total); - if (response.status == 200) { - setFilteredEquipments(response.data.records || []); - setTotalCount(response.data.total || 0); - } else { - throw "400"; - } - } catch (error) { - console.error("Error fetching equipment types:", error); - setFilteredEquipments([]); - setTotalCount(0); - } finally { - setIsLoading(false); - } - }, - [pagingController.pageNum, pagingController.pageSize, tabIndex], - ); - - useEffect(() => { - if (isReady) { - refetchData(filterObj); - } - }, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]); - - const onReset = useCallback(() => { - setFilterObjByTab(prev => ({ - ...prev, - [tabIndex]: {}, - })); - setPagingControllerByTab(prev => ({ - ...prev, - [tabIndex]: { - pageNum: 1, - pageSize: prev[tabIndex]?.pageSize || 10, - }, - })); - }, [tabIndex]); - - const handleSaveEquipmentDetail = useCallback(async () => { - if (!selectedName || !selectedDescription) { - return; - } - - if (!isExistingCombination) { - if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) { - alert("請輸入3個大寫英文字母作為設備編號前綴"); - return; - } - if (!equipmentCodeNumber) { - alert("設備編號生成中,請稍候"); - return; - } - } - - setSaving(true); - try { - - const equipmentCode = `${selectedDescription}-${selectedName}`; - - let equipment = equipmentList.find((eq) => eq.code === equipmentCode); - let equipmentId: string | number; - - if (!equipment) { - const equipmentResponse = await axiosInstance.post( - `${NEXT_PUBLIC_API_URL}/Equipment/save`, - { - code: equipmentCode, - name: selectedName, - description: selectedDescription, - id: null, - } - ); - equipment = equipmentResponse.data; - equipmentId = equipment.id; - } else { - equipmentId = equipment.id; - } - - const existingDetailsResponse = await axiosInstance.get<{ - records: EquipmentResult[]; - total: number; - }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`); - - let newName = "1號"; - let newEquipmentCode = ""; - - if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) { - const numbers = existingDetailsResponse.data.records - .map((detail) => { - const match = detail.name?.match(/(\d+)號/); - return match ? parseInt(match[1], 10) : 0; - }) - .filter((num) => num > 0); - - if (numbers.length > 0) { - const maxNumber = Math.max(...numbers); - newName = `${maxNumber + 1}號`; - } - - if (isExistingCombination) { - const equipmentCodePatterns = existingDetailsResponse.data.records - .map((detail) => { - if (!detail.equipmentCode) return null; - const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/); - if (match) { - const originalNumber = match[2]; - return { - prefix: match[1], - number: parseInt(match[2], 10), - paddingLength: originalNumber.length - }; - } - return null; - }) - .filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null); - - if (equipmentCodePatterns.length > 0) { - const prefix = equipmentCodePatterns[0].prefix; - const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number)); - const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength)); - const nextNumber = maxEquipmentCodeNumber + 1; - newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`; - } else { - newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; - } - } else { - if (isExistingCombination) { - newEquipmentCode = selectedEquipmentCode; - } else { - newEquipmentCode = `${equipmentCodePrefix}${equipmentCodeNumber}`; - } - } - } else { - if (isExistingCombination) { - newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; - } else { - newEquipmentCode = `${equipmentCodePrefix}${equipmentCodeNumber}`; - } - } - - const detailCode = `${equipmentCode}-${newName}`; - - await axiosInstance.post( - `${NEXT_PUBLIC_API_URL}/EquipmentDetail/save`, - { - code: detailCode, - name: newName, - description: equipmentCode, - equipmentCode: newEquipmentCode, - id: null, - equipmentTypeId: equipmentId, - repairAndMaintenanceStatus: false, - } - ); - - handleAddDialogClose(); - - if (tabIndex === 0) { - await refetchData(filterObj); - - if (equipmentDetailsMap.has(equipmentId)) { - await fetchEquipmentDetailsByEquipmentId(equipmentId); - } - } - - alert("新增成功"); - } catch (error: any) { - console.error("Error saving equipment detail:", error); - const errorMessage = error.response?.data?.message || error.message || "保存失敗,請稍後再試"; - alert(errorMessage); - } finally { - setSaving(false); - } - }, [selectedName, selectedDescription, selectedEquipmentCode, equipmentCodePrefix, equipmentCodeNumber, isExistingCombination, equipmentList, refetchData, filterObj, handleAddDialogClose, tabIndex, equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]); - - const renderExpandedRow = useCallback((item: EquipmentResult): React.ReactNode => { - if (tabIndex !== 0) { - return null; - } - - const details = equipmentDetailsMap.get(item.id) || []; - const isLoading = loadingDetailsMap.get(item.id) || false; - - return ( - - - - - {isLoading ? ( - - - 載入中... - - ) : details.length === 0 ? ( - 無相關設備詳細資料 - ) : ( - - - 設備詳細資料 (設備編號: {item.code}) - - - {details.map((detail) => ( - - - - - 編號: {detail.code || "-"} - - handleDeleteClick(detail.id, item.id)} - sx={{ ml: 1 }} - > - - - - {detail.name && ( - - 名稱: {detail.name} - - )} - {detail.description && ( - - 描述: {detail.description} - - )} - {detail.equipmentCode && ( - - 設備編號: {detail.equipmentCode} - - )} - - - ))} - - - )} - - - - - ); - }, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]); - - return ( - <> - { - setFilterObjByTab(prev => { - const newState = { ...prev }; - newState[tabIndex] = query as unknown as SearchQuery; - return newState; - }); - }} - onReset={onReset} - /> - {tabIndex === 0 && ( - - - 設備編號 - - - - )} - - - items={filteredEquipments} - columns={columns} - setPagingController={(newController) => { - setPagingControllerByTab(prev => { - const newState = { ...prev }; - newState[tabIndex] = typeof newController === 'function' - ? newController(prev[tabIndex] || { pageNum: 1, pageSize: 10 }) - : newController; - return newState; - }); - }} - pagingController={pagingController} - totalCount={totalCount} - isAutoPaging={false} - renderExpandedRow={renderExpandedRow} - hideHeader={tabIndex === 0} - /> - - - {/* Delete Confirmation Dialog */} - {deleteDialogOpen && ( - - - 確認刪除 - - - - 您確定要刪除此設備詳細資料嗎?此操作無法復原。 - - - - - - - - )} - - {/* Add Equipment Detail Dialog */} - - - 新增設備詳細資料 - - - - { - setSelectedDescription(newValue || ''); - }} - onInputChange={(event, newInputValue) => { - setSelectedDescription(newInputValue); - }} - loading={loadingEquipments} - disabled={loadingEquipments || saving} - renderInput={(params) => ( - - )} - sx={{ mb: 2 }} - /> - { - setSelectedName(newValue || ''); - }} - onInputChange={(event, newInputValue) => { - setSelectedName(newInputValue); - }} - loading={loadingEquipments} - disabled={loadingEquipments || saving} - componentsProps={{ - popper: { - placement: 'bottom-start', - modifiers: [ - { - name: 'flip', - enabled: false, - }, - { - name: 'preventOverflow', - enabled: true, - }, - ], - }, - }} - renderInput={(params) => ( - - )} - /> - - { - if (!isExistingCombination) { - const input = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3); - setEquipmentCodePrefix(input); - } - }} - disabled={isExistingCombination || loadingEquipments || saving} - placeholder={isExistingCombination ? "自動生成" : "輸入3個大寫英文字母"} - required={!isExistingCombination} - InputProps={{ - endAdornment: !isExistingCombination && equipmentCodeNumber ? ( - - - {equipmentCodeNumber} - - - ) : null, - }} - helperText={!isExistingCombination && equipmentCodePrefix.length > 0 && equipmentCodePrefix.length !== 3 - ? "必須輸入3個大寫英文字母" - : !isExistingCombination && equipmentCodePrefix.length === 3 && !/^[A-Z]{3}$/.test(equipmentCodePrefix) - ? "必須是大寫英文字母" - : ""} - error={!isExistingCombination && equipmentCodePrefix.length > 0 && (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix))} - /> - - - - - - - - - - ); - }; - - export default EquipmentSearch; \ No newline at end of file diff --git a/src/components/ImportBom/EquipmentSearchLoading.tsx b/src/components/ImportBom/EquipmentSearchLoading.tsx deleted file mode 100644 index 40a4253..0000000 --- a/src/components/ImportBom/EquipmentSearchLoading.tsx +++ /dev/null @@ -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 ( - <> - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export default EquipmentTypeSearchLoading; diff --git a/src/components/ImportBom/EquipmentSearchResults.tsx b/src/components/ImportBom/EquipmentSearchResults.tsx deleted file mode 100644 index dd2c45e..0000000 --- a/src/components/ImportBom/EquipmentSearchResults.tsx +++ /dev/null @@ -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 { - name: keyof T; - label: string; - align?: TableCellProps["align"]; - headerAlign?: TableCellProps["align"]; - sx?: SxProps | undefined; - style?: Partial & { [propName: string]: string }; - type?: ColumnType; - renderCell?: (params: T) => React.ReactNode; - renderHeader?: () => React.ReactNode; -} - -interface IconColumn extends BaseColumn { - 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 extends BaseColumn { - type: "decimal"; -} - -interface IntegerColumn extends BaseColumn { - type: "integer"; -} - -interface CheckboxColumn extends BaseColumn { - type: "checkbox"; - disabled?: (params: T) => boolean; - // checkboxIds: readonly (string | number)[], - // setCheckboxIds: (ids: readonly (string | number)[]) => void -} - -interface ColumnWithAction extends BaseColumn { - onClick: (item: T) => void; - buttonIcon: React.ReactNode; - buttonIcons: { [columnValue in keyof T]: React.ReactNode }; - buttonColor?: IconButtonOwnProps["color"]; -} - -export type Column = - | BaseColumn - | IconColumn - | DecimalColumn - | CheckboxColumn - | ColumnWithAction; - -interface Props { - totalCount?: number; - items: T[]; - columns: Column[]; - noWrapper?: boolean; - setPagingController?: Dispatch< - SetStateAction<{ - pageNum: number; - pageSize: number; - }> - >; - pagingController?: { pageNum: number; pageSize: number }; - isAutoPaging?: boolean; - checkboxIds?: (string | number)[]; - setCheckboxIds?: Dispatch>; - onRowClick?: (item: T) => void; - renderExpandedRow?: (item: T) => React.ReactNode; - hideHeader?: boolean; -} - -function isActionColumn( - column: Column, -): column is ColumnWithAction { - return Boolean((column as ColumnWithAction).onClick); -} - -function isIconColumn( - column: Column, -): column is IconColumn { - return column.type === "icon"; -} - -function isDecimalColumn( - column: Column, -): column is DecimalColumn { - return column.type === "decimal"; -} - -function isIntegerColumn( - column: Column, -): column is IntegerColumn { - return column.type === "integer"; -} - -function isCheckboxColumn( - column: Column, -): column is CheckboxColumn { - return column.type === "checkbox"; -} - -function convertObjectKeysToLowercase( - obj: T, -): object | undefined { - return obj - ? Object.fromEntries( - Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]), - ) - : undefined; -} - -function handleIconColors( - column: IconColumn, - 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( - column: IconColumn, - 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 ?? ; -} -export const defaultPagingController: { pageNum: number; pageSize: number } = { - pageNum: 1, - pageSize: 10, -}; - -export type defaultSetPagingController = Dispatch< - SetStateAction<{ - pageNum: number; - pageSize: number; - }> -> - -function EquipmentSearchResults({ - items, - columns, - noWrapper, - pagingController, - setPagingController, - isAutoPaging = true, - totalCount, - checkboxIds = [], - setCheckboxIds = undefined, - onRowClick = undefined, - renderExpandedRow = undefined, - hideHeader = false, -}: Props) { - 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, item: T, columns: Column[]) => { - 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) => { - 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 = ( - <> - - - {!hideHeader && ( - - - {columns.map((column, idx) => ( - isCheckboxColumn(column) ? - - 0 && currItemsWithChecked.length < currItems.length} - checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} - onChange={handleSelectAllClick} - /> - - : - {column.renderHeader ? ( - column.renderHeader() - ) : ( - column.label.split('\n').map((line, index) => ( -
{line}
- )) - )} -
- ))} -
-
- )} - - {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 ( - - { - setCheckboxIds - ? handleRowClick(event, item, columns) - : undefined - - if (onRowClick) { - onRowClick(item) - } - } - } - role={setCheckboxIds ? "checkbox" : undefined} - > - {columns.map((column, idx) => { - const columnName = column.name; - - return ( - - ); - })} - - {renderExpandedRow && renderExpandedRow(item)} - - ); - }) - : items.map((item) => { - return ( - - { - setCheckboxIds - ? handleRowClick(event, item, columns) - : undefined - - if (onRowClick) { - onRowClick(item) - } - } - } - role={setCheckboxIds ? "checkbox" : undefined} - > - {columns.map((column, idx) => { - const columnName = column.name; - - return ( - - ); - })} - - {renderExpandedRow && renderExpandedRow(item)} - - ); - })} - -
-
- - `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` - } -/> - - ); - - return noWrapper ? table : {table}; -} - -interface TableCellsProps { - column: Column; - columnName: keyof T; - idx: number; - item: T; - checkboxIds: (string | number)[]; -} - -function TabelCells({ - column, - columnName, - idx, - item, - checkboxIds = [], -}: TableCellsProps) { - const isItemSelected = checkboxIds.includes(item.id); - - return ( - - {isActionColumn(column) ? ( - column.onClick(item)} - > - {column.buttonIcon} - - ) : isIconColumn(column) ? ( - - {handleIconIcons(column, item[columnName])} - - ) : isDecimalColumn(column) ? ( - <>{decimalFormatter.format(Number(item[columnName]))} - ) : isIntegerColumn(column) ? ( - <>{integerFormatter.format(Number(item[columnName]))} - ) : isCheckboxColumn(column) ? ( - - ) : column.renderCell ? ( - column.renderCell(item) - ) : ( - <>{item[columnName] as string} - )} - - ); -} - -export default EquipmentSearchResults; \ No newline at end of file diff --git a/src/components/ImportBom/EquipmentSearchWrapper.tsx b/src/components/ImportBom/EquipmentSearchWrapper.tsx deleted file mode 100644 index a1cf35d..0000000 --- a/src/components/ImportBom/EquipmentSearchWrapper.tsx +++ /dev/null @@ -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 ( - <> - - - - ); -}; - -EquipmentSearchWrapper.Loading = EquipmentSearchLoading; - -export default EquipmentSearchWrapper; \ No newline at end of file diff --git a/src/components/ImportBom/ImportBomDetailTab.tsx b/src/components/ImportBom/ImportBomDetailTab.tsx new file mode 100644 index 0000000..9c5ef84 --- /dev/null +++ b/src/components/ImportBom/ImportBomDetailTab.tsx @@ -0,0 +1,177 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { + Box, + Stack, + Typography, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Paper, + Table, + TableHead, + TableRow, + TableCell, + TableBody, +} from "@mui/material"; +import type { BomCombo, BomDetailResponse } from "@/app/api/bom"; +import { fetchBomComboClient, fetchBomDetailClient } from "@/app/api/bom/client"; +import type { SelectChangeEvent } from "@mui/material/Select"; +import { useTranslation } from "react-i18next"; +const ImportBomDetailTab: React.FC = () => { + const { t } = useTranslation( "common" ); + const [bomList, setBomList] = useState([]); + const [selectedBomId, setSelectedBomId] = useState(""); + const [detail, setDetail] = useState(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) => { + 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 ( + + + + {t("Please Select BOM")} + + + + + {loadingDetail && ( + + {t("Loading BOM Detail...")} + + )} + + {detail && ( + + + {detail.itemCode} {detail.itemName}({t("Output Quantity")} {detail.outputQty}{" "} + {detail.outputQtyUom}) + + + {/* 材料列表 */} + + + 材料 (Bom Material) + + + + + {t("Item Code")} + {t("Item Name")} + {t("Base Qty")} + {t("Base UOM")} + {t("Stock Qty")} + {t("Stock UOM")} + {t("Sales Qty")} + {t("Sales UOM")} + + + + {detail.materials.map((m, i) => ( + + {m.itemCode} + {m.itemName} + {m.baseQty} + {m.baseUom} + {m.stockQty} + {m.stockUom} + {m.salesQty} + {m.salesUom} + + ))} + +
+
+ + {/* 製程 + 設備列表 */} + + + {t("Process & Equipment")} + + + + + {t("Sequence")} + {t("Process Name")} + {t("Process Description")} + {t("Equipment Name")} + {t("Duration (Minutes)")} + {t("Prep Time (Minutes)")} + {t("Post Prod Time (Minutes)")} + + + + {detail.processes.map((p, i) => ( + + {p.seqNo} + {p.processName} + {p.processDescription} + {p.equipmentName} + + {p.durationInMinute} + + + {p.prepTimeInMinute} + + + {p.postProdTimeInMinute} + + + ))} + +
+
+
+ )} +
+ ); +}; + +export default ImportBomDetailTab; \ No newline at end of file diff --git a/src/components/ImportBom/ImportBomResultForm.tsx b/src/components/ImportBom/ImportBomResultForm.tsx index 9435ddd..7b0e007 100644 --- a/src/components/ImportBom/ImportBomResultForm.tsx +++ b/src/components/ImportBom/ImportBomResultForm.tsx @@ -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(() => - correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false })) + correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false, isDrink: false })) ); const [submitting, setSubmitting] = useState(false); const [successMsg, setSuccessMsg] = useState(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({ > - 正確 BOM 列表(可匯入) + {t("Correct BOM List (Can Import)")} + + {t("WIP")} + {t("Drink")} + {t("File Name")} + {filteredCorrect.map((item) => ( - - - handleToggleWip(item.fileName) - } - size="small" - /> - - {item.fileName} - - - {item.isAlsoWip ? "同時建 WIP" : ""} - - + + handleToggleWip(item.fileName)} + size="small" + /> + handleToggleDrink(item.fileName)} + size="small" + /> + + {item.fileName} + + + ))} - 失敗 BOM 列表 + {t("Issue BOM List")} {failList.length === 0 ? ( @@ -201,6 +227,13 @@ export default function ImportBomResultForm({ > 確認匯入 + {submitting && } {successMsg && ( diff --git a/src/components/ImportBom/ImportBomUpload.tsx b/src/components/ImportBom/ImportBomUpload.tsx index 6a0e073..ab48a9a 100644 --- a/src/components/ImportBom/ImportBomUpload.tsx +++ b/src/components/ImportBom/ImportBomUpload.tsx @@ -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([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - + const [progress, setProgress] = useState(null); +const [startTime, setStartTime] = useState(null); const handleFileChange = (e: React.ChangeEvent) => { 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 ( @@ -114,6 +135,20 @@ export default function ImportBomUpload({ onSuccess }: Props) { {loading ? "上傳與檢查中…" : "上傳並檢查"} {loading && } + {loading && progress && ( + + 已檢查 {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} 秒`; + })()} + + )} {error && ( setError(null)}> diff --git a/src/components/ImportBom/ImportBomWrapper.tsx b/src/components/ImportBom/ImportBomWrapper.tsx index 5434f2e..a6d8c52 100644 --- a/src/components/ImportBom/ImportBomWrapper.tsx +++ b/src/components/ImportBom/ImportBomWrapper.tsx @@ -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(null); - const [formatResults, setFormatResults] = useState(null); - const [uploadedCount, setUploadedCount] = useState(0); - - const handleUploadSuccess = ( - id: string, - results: BomFormatCheckResponse, - count: number - ) => { - setBatchId(id); - setFormatResults(results); - setUploadedCount(count); - }; - - const handleBack = () => { - setBatchId(null); - setFormatResults(null); - }; - - return ( - - {formatResults === null ? ( - - ) : batchId ? ( - - ) : null} - - ); -} + const [batchId, setBatchId] = useState(null); + const [formatResults, setFormatResults] = + useState(null); + const [uploadedCount, setUploadedCount] = useState(0); + const [currentTab, setCurrentTab] = useState(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 ( + + + + + + + + + {/* Tab 0: 原本匯入流程 */} + {currentTab === 0 && ( + + {formatResults === null ? ( + + ) : batchId ? ( + + ) : null} + + )} + + {/* Tab 1: BOM 詳細資料 */} + {currentTab === 1 && ( + + + + )} + + ); +} \ No newline at end of file diff --git a/src/components/QcCategorySave/QcCategoryDetails.tsx b/src/components/QcCategorySave/QcCategoryDetails.tsx index 896c088..15f77fa 100644 --- a/src/components/QcCategorySave/QcCategoryDetails.tsx +++ b/src/components/QcCategorySave/QcCategoryDetails.tsx @@ -46,7 +46,9 @@ const QcCategoryDetails = () => { { const [validatingItemCode, setValidatingItemCode] = useState(false); const [selectedType, setSelectedType] = useState("IQC"); const [loading, setLoading] = useState(true); - + const [categoryType, setCategoryType] = useState("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[] = 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 */} - setOpenDialog(false)} maxWidth="md" fullWidth> + setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}> {t("Mapping Details")} - {selectedCategory?.name} - - + + + + + {t("Category Type")} + + setCategoryType(e.target.value)} + SelectProps={{ native: true }} + > + {typeOptions.map((opt) => ( + + ))} + + + + + + @@ -274,7 +405,9 @@ const Tab0ItemQcCategoryMapping: React.FC = () => { {mapping.itemCode} {mapping.itemName} - {mapping.type} + + {formatTypeDisplay(mapping.type)} + { {/* Add Mapping Dialog */} - setOpenAddDialog(false)} maxWidth="sm" fullWidth> + setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}> {t("Add Mapping")} diff --git a/src/components/QcItemAll/Tab1QcCategoryQcItemMapping.tsx b/src/components/QcItemAll/Tab1QcCategoryQcItemMapping.tsx index 5544dfb..1532de5 100644 --- a/src/components/QcItemAll/Tab1QcCategoryQcItemMapping.tsx +++ b/src/components/QcItemAll/Tab1QcCategoryQcItemMapping.tsx @@ -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 */} - setOpenDialog(false)} maxWidth="md" fullWidth> + setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}> {t("Association Details")} - {selectedCategory?.name} @@ -263,7 +264,7 @@ const Tab1QcCategoryQcItemMapping: React.FC = () => { {/* Add Mapping Dialog */} - setOpenAddDialog(false)} maxWidth="sm" fullWidth> + setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}> {t("Add Association")} diff --git a/src/components/QcItemAll/Tab2QcCategoryManagement.tsx b/src/components/QcItemAll/Tab2QcCategoryManagement.tsx index 5e0992c..bd2698b 100644 --- a/src/components/QcItemAll/Tab2QcCategoryManagement.tsx +++ b/src/components/QcItemAll/Tab2QcCategoryManagement.tsx @@ -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 */} - setOpenDialog(false)} maxWidth="md" fullWidth> + setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}> {editingCategory ? t("Edit Qc Category") : t("Create Qc Category")} diff --git a/src/components/QcItemAll/Tab3QcItemManagement.tsx b/src/components/QcItemAll/Tab3QcItemManagement.tsx index 33591fc..156f54f 100644 --- a/src/components/QcItemAll/Tab3QcItemManagement.tsx +++ b/src/components/QcItemAll/Tab3QcItemManagement.tsx @@ -200,7 +200,7 @@ const Tab3QcItemManagement: React.FC = () => { /> {/* Add/Edit Dialog */} - setOpenDialog(false)} maxWidth="md" fullWidth> + setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}> {editingItem ? t("Edit Qc Item") : t("Create Qc Item")}