diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx index d259047..d965477 100644 --- a/src/app/(main)/dashboard/page.tsx +++ b/src/app/(main)/dashboard/page.tsx @@ -18,7 +18,7 @@ const Dashboard: React.FC = async ({ searchParams }) => { fetchEscalationLogsByUser() return ( - + }> diff --git a/src/app/(main)/productionProcess/page.tsx b/src/app/(main)/productionProcess/page.tsx index d3eea1f..8c4c901 100644 --- a/src/app/(main)/productionProcess/page.tsx +++ b/src/app/(main)/productionProcess/page.tsx @@ -38,7 +38,7 @@ const productionProcess: React.FC = async () => { {t("Create Process")} */} - + diff --git a/src/app/(main)/settings/importBom/EquipmentTabs.tsx b/src/app/(main)/settings/importBom/EquipmentTabs.tsx new file mode 100644 index 0000000..d4e6a5b --- /dev/null +++ b/src/app/(main)/settings/importBom/EquipmentTabs.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import { useTranslation } from "react-i18next"; +import { useRouter, useSearchParams } from "next/navigation"; + +type EquipmentTabsProps = { + onTabChange?: (tabIndex: number) => void; +}; + +const EquipmentTabs: React.FC = ({ onTabChange }) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const { t } = useTranslation("common"); + + 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; + if (newTabIndex !== tabIndex) { + setTabIndex(newTabIndex); + onTabChange?.(newTabIndex); + } + }, [searchParams, tabIndex, onTabChange]); + + const handleTabChange = (_e: React.SyntheticEvent, newValue: number) => { + setTabIndex(newValue); + onTabChange?.(newValue); + + const params = new URLSearchParams(searchParams.toString()); + if (newValue === 0) { + params.delete("tab"); + } else { + params.set("tab", newValue.toString()); + } + router.push(`/settings/equipment?${params.toString()}`, { scroll: false }); + }; + + return ( + + + + + ); +}; + +export default EquipmentTabs; \ No newline at end of file diff --git a/src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx b/src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx new file mode 100644 index 0000000..65c233f --- /dev/null +++ b/src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { SearchParams } from "@/app/utils/fetchUtil"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import { Typography } from "@mui/material"; +import isString from "lodash/isString"; +import { notFound } from "next/navigation"; +import UpdateMaintenanceForm from "@/components/UpdateMaintenance/UpdateMaintenanceForm"; + +type Props = {} & SearchParams; + +const MaintenanceEditPage: React.FC = async ({ searchParams }) => { + const type = "common"; + const { t } = await getServerI18n(type); + const id = isString(searchParams["id"]) + ? parseInt(searchParams["id"]) + : undefined; + if (!id) { + notFound(); + } + return ( + <> + {t("Update Equipment Maintenance and Repair")} + + + + + ); +}; +export default MaintenanceEditPage; \ No newline at end of file diff --git a/src/app/(main)/settings/importBom/create/page.tsx b/src/app/(main)/settings/importBom/create/page.tsx new file mode 100644 index 0000000..ae48446 --- /dev/null +++ b/src/app/(main)/settings/importBom/create/page.tsx @@ -0,0 +1,22 @@ +import { SearchParams } from "@/app/utils/fetchUtil"; +import { TypeEnum } from "@/app/utils/typeEnum"; +import CreateEquipmentType from "@/components/CreateEquipment"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import { Typography } from "@mui/material"; +import isString from "lodash/isString"; + +type Props = {} & SearchParams; + +const materialSetting: React.FC = async ({ searchParams }) => { + // const type = TypeEnum.PRODUCT; + const { t } = await getServerI18n("common"); + return ( + <> + {/* {t("Create Material")} */} + + + + + ); +}; +export default materialSetting; diff --git a/src/app/(main)/settings/importBom/edit/page.tsx b/src/app/(main)/settings/importBom/edit/page.tsx new file mode 100644 index 0000000..41e401c --- /dev/null +++ b/src/app/(main)/settings/importBom/edit/page.tsx @@ -0,0 +1,29 @@ +import { SearchParams } from "@/app/utils/fetchUtil"; +import { TypeEnum } from "@/app/utils/typeEnum"; +import CreateEquipmentType from "@/components/CreateEquipment"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import { Typography } from "@mui/material"; +import isString from "lodash/isString"; +import { notFound } from "next/navigation"; + +type Props = {} & SearchParams; + +const productSetting: React.FC = async ({ searchParams }) => { + const type = "common"; + const { t } = await getServerI18n(type); + const id = isString(searchParams["id"]) + ? parseInt(searchParams["id"]) + : undefined; + if (!id) { + notFound(); + } + return ( + <> + {/* {t("Create Material")} */} + + + + + ); +}; +export default productSetting; diff --git a/src/app/(main)/settings/importBom/page.tsx b/src/app/(main)/settings/importBom/page.tsx new file mode 100644 index 0000000..7285cd7 --- /dev/null +++ b/src/app/(main)/settings/importBom/page.tsx @@ -0,0 +1,29 @@ +import { Metadata } from "next"; +import { I18nProvider } from "@/i18n"; +import ImportBomWrapper from "@/components/ImportBom/ImportBomWrapper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; + +export const metadata: Metadata = { + title: "Import BOM", +}; + +export default async function ImportBomPage() { + return ( + <> + + + Import BOM + + + + + + + ); +} diff --git a/src/app/(main)/settings/qcItemAll/page.tsx b/src/app/(main)/settings/qcItemAll/page.tsx index 142179a..cb257ac 100644 --- a/src/app/(main)/settings/qcItemAll/page.tsx +++ b/src/app/(main)/settings/qcItemAll/page.tsx @@ -51,6 +51,19 @@ export default qcItemAll; + + + + + + + + + + + + + diff --git a/src/app/api/bom/client.ts b/src/app/api/bom/client.ts new file mode 100644 index 0000000..8d7fa05 --- /dev/null +++ b/src/app/api/bom/client.ts @@ -0,0 +1,53 @@ +"use client"; + +import axiosInstance from "@/app/(main)/axios/axiosInstance"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import type { + BomFormatCheckResponse, + BomUploadResponse, + ImportBomItemPayload, +} from "./index"; + +export async function uploadBomFiles( + files: File[] +): Promise { + const formData = new FormData(); + files.forEach((f) => formData.append("files", f, f.name)); + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/import-bom/upload`, + formData, + { + transformRequest: [ + (data: unknown, headers?: Record) => { + if (data instanceof FormData && headers && "Content-Type" in headers) { + delete headers["Content-Type"]; + } + return data; + }, + ], + } + ); + return response.data; +} + +export async function checkBomFormat( + batchId: string +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/import-bom/format-check`, + { batchId } + ); + return response.data; +} + +export async function importBom( + batchId: string, + items: ImportBomItemPayload[] +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/bom/import-bom`, + { batchId, items }, + { responseType: "blob" } + ); + return response.data as Blob; +} diff --git a/src/app/api/escalation/index.ts b/src/app/api/escalation/index.ts index 3b1031b..b3f6506 100644 --- a/src/app/api/escalation/index.ts +++ b/src/app/api/escalation/index.ts @@ -30,6 +30,8 @@ export interface EscalationResult { qcFailCount?: number; qcTotalCount?: number; poCode?: string; + jobOrderId?: number; + jobOrderCode?: string; itemCode?: string; dnDate?: number[]; dnNo?: string; diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index ee6a566..de1167e 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -674,12 +674,13 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder }, ); }); -export const fetchAllJoPickOrders = cache(async () => { +export const fetchAllJoPickOrders = cache(async (isDrink?: boolean | null) => { + const query = isDrink !== undefined && isDrink !== null + ? `?isDrink=${isDrink}` + : ""; return serverFetchJson( - `${BASE_API_URL}/jo/AllJoPickOrder`, - { - method: "GET", - } + `${BASE_API_URL}/jo/AllJoPickOrder${query}`, + { method: "GET" } ); }); export const fetchProductProcessLineDetail = cache(async (lineId: number) => { diff --git a/src/components/DashboardPage/escalation/EscalationLogTable.tsx b/src/components/DashboardPage/escalation/EscalationLogTable.tsx index 17c3fa2..8e5f35c 100644 --- a/src/components/DashboardPage/escalation/EscalationLogTable.tsx +++ b/src/components/DashboardPage/escalation/EscalationLogTable.tsx @@ -10,7 +10,11 @@ import { Column } from "@/components/SearchResults"; import SearchResults from "@/components/SearchResults/SearchResults"; import { arrayToDateString, arrayToDateTimeString } from "@/app/utils/formatUtil"; import { CardFilterContext } from "@/components/CollapsibleCard/CollapsibleCard"; - +import QcStockInModal from "@/components/Qc/QcStockInModal"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; +import { StockInLineInput } from "@/app/api/stockIn"; +import { PrinterCombo } from "@/app/api/settings/printer"; export type IQCItems = { id: number; poId: number; @@ -25,20 +29,25 @@ export type IQCItems = { type Props = { type?: "dashboard" | "qc"; items: EscalationResult[]; + printerCombo?: PrinterCombo[]; }; const EscalationLogTable: React.FC = ({ - type = "dashboard", items + type = "dashboard", items, printerCombo }) => { - const { t } = useTranslation("dashboard"); + const { t } = useTranslation(["dashboard","purchaseOrder"]); const CARD_HEADER = t("stock in escalation list") const pathname = usePathname(); const router = useRouter(); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const sessionToken = session as SessionWithTokens | null; const [selectedId, setSelectedId] = useState(null); const [escalationLogs, setEscalationLogs] = useState([]); - + const [openModal, setOpenModal] = useState(false); +const [modalInfo, setModalInfo] = useState(); +const [modalPrintSource, setModalPrintSource] = useState<"stockIn" | "productionProcess">("stockIn"); const useCardFilter = useContext(CardFilterContext); const showCompleted = useMemo(() => { if (type === "dashboard") { @@ -54,7 +63,7 @@ const EscalationLogTable: React.FC = ({ setEscalationLogs(filteredEscLog); } }, [showCompleted, items]) - + /* const navigateTo = useCallback( (item: EscalationResult) => { setSelectedId(item.id); @@ -63,13 +72,27 @@ const EscalationLogTable: React.FC = ({ }, [router, pathname] ); + */ const onRowClick = useCallback((item: EscalationResult) => { - if (type == "dashboard") { - router.push(`/po/edit?id=${item.poId}&selectedIds=${item.poId}&polId=${item.polId}&stockInLineId=${item.stockInLineId}`); + if (type !== "dashboard") return; + + if (!item.stockInLineId) { + alert(t("Invalid Stock In Line Id")); + return; } - }, [router]) - + + setModalInfo({ + id: item.stockInLineId, + }); + + setModalPrintSource(item.jobOrderId ? "productionProcess" : "stockIn"); + setOpenModal(true); + }, [type, t]); + const closeNewModal = useCallback(() => { + setOpenModal(false); + setModalInfo(undefined); + }, []); // const handleKeyDown = useCallback( // (e: React.KeyboardEvent, item: EscalationResult) => { // if (e.key === 'Enter' || e.key === ' ') { @@ -119,10 +142,13 @@ const EscalationLogTable: React.FC = ({ }, { name: "poCode", - label: t("Po Code"), + label: t("Po Code/Jo Code"), align: "left", headerAlign: "left", sx: { width: "15%", minWidth: 100 }, + renderCell: (params) => { + return params.jobOrderCode ?? params.poCode ?? "-"; + } }, { name: "recordDate", @@ -255,14 +281,28 @@ const EscalationLogTable: React.FC = ({ );*/} - return ( - + return ( + <> + + + + ) + }; export default EscalationLogTable; diff --git a/src/components/ImportBom/EquipmentSearch.tsx b/src/components/ImportBom/EquipmentSearch.tsx new file mode 100644 index 0000000..82c4dd1 --- /dev/null +++ b/src/components/ImportBom/EquipmentSearch.tsx @@ -0,0 +1,1039 @@ +"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 new file mode 100644 index 0000000..40a4253 --- /dev/null +++ b/src/components/ImportBom/EquipmentSearchLoading.tsx @@ -0,0 +1,41 @@ +"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 new file mode 100644 index 0000000..dd2c45e --- /dev/null +++ b/src/components/ImportBom/EquipmentSearchResults.tsx @@ -0,0 +1,492 @@ +"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 new file mode 100644 index 0000000..a1cf35d --- /dev/null +++ b/src/components/ImportBom/EquipmentSearchWrapper.tsx @@ -0,0 +1,35 @@ +"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/ImportBomResultForm.tsx b/src/components/ImportBom/ImportBomResultForm.tsx new file mode 100644 index 0000000..9435ddd --- /dev/null +++ b/src/components/ImportBom/ImportBomResultForm.tsx @@ -0,0 +1,219 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import InputAdornment from "@mui/material/InputAdornment"; +import Checkbox from "@mui/material/Checkbox"; +import Accordion from "@mui/material/Accordion"; +import AccordionSummary from "@mui/material/AccordionSummary"; +import AccordionDetails from "@mui/material/AccordionDetails"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +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 }; + +type Props = { + batchId: string; + correctFileNames: string[]; + failList: BomFormatFileGroup[]; + uploadedCount: number; + onBack?: () => void; +}; + +export default function ImportBomResultForm({ + batchId, + correctFileNames, + failList, + uploadedCount, + onBack, +}: Props) { + const [search, setSearch] = useState(""); + const [items, setItems] = useState(() => + correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false })) + ); + const [submitting, setSubmitting] = useState(false); + const [successMsg, setSuccessMsg] = useState(null); + + const filteredCorrect = useMemo(() => { + if (!search.trim()) return items; + const q = search.trim().toLowerCase(); + return items.filter((i) => i.fileName.toLowerCase().includes(q)); + }, [items, search]); + + const handleToggleWip = (fileName: string) => { + setItems((prev) => + prev.map((x) => + x.fileName === fileName + ? { ...x, isAlsoWip: !x.isAlsoWip } + : x + ) + ); + }; + + 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。"); + } catch (err) { + console.error(err); + setSuccessMsg("匯入失敗,請查看主控台。"); + } finally { + setSubmitting(false); + } + }; + + const wipCount = items.filter((i) => i.isAlsoWip).length; + const totalChecked = correctFileNames.length + failList.length; + + return ( + + + {onBack && ( + + )} + + + 已上傳 {uploadedCount} 個檔案,檢查結果共 {totalChecked} 筆:正確 {correctFileNames.length} 個、失敗 {failList.length} 個 + + {uploadedCount !== totalChecked && ( + + 上傳數與檢查筆數不符,可能因檔名重複;重新上傳後會為重複檔名自動加 _2、_3 等區分,全部都會列入檢查。 + + )} + + + + + + + 正確 BOM 列表(可匯入) + + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ mb: 2, width: "100%" }} + /> + + {filteredCorrect.map((item) => ( + + + handleToggleWip(item.fileName) + } + size="small" + /> + + {item.fileName} + + + {item.isAlsoWip ? "同時建 WIP" : ""} + + + ))} + + + + + + 失敗 BOM 列表 + + {failList.length === 0 ? ( + + 無 + + ) : ( + failList.map((f) => ( + + }> + + {f.fileName} + + + + + {f.problems.map((p, i) => ( + + {p} + + ))} + + + + )) + )} + + + + + + {submitting && } + {successMsg && ( + + {successMsg} + + )} + + {items.length > 0 && ( + + 將匯入 {items.length} 個 BOM + {wipCount > 0 ? `,其中 ${wipCount} 個同時建立 WIP` : ""} + + )} + + ); +} diff --git a/src/components/ImportBom/ImportBomUpload.tsx b/src/components/ImportBom/ImportBomUpload.tsx new file mode 100644 index 0000000..6a0e073 --- /dev/null +++ b/src/components/ImportBom/ImportBomUpload.tsx @@ -0,0 +1,127 @@ +"use client"; + +import React, { useState } from "react"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import CircularProgress from "@mui/material/CircularProgress"; +import Box from "@mui/material/Box"; +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 type { BomFormatCheckResponse } from "@/app/api/bom"; + +type Props = { + onSuccess: ( + batchId: string, + results: BomFormatCheckResponse, + uploadedCount: number + ) => void; +}; + +function getErrorMessage(err: unknown): string { + if (err && typeof err === "object" && "response" in err) { + const res = (err as { response?: { status?: number; data?: unknown } }) + .response; + if (res?.status === 500) + return "伺服器錯誤 (500),請確認後端服務已啟動且 API 路徑正確。"; + if (res?.data && typeof res.data === "string" && res.data.length < 200) + return res.data; + } + if (err && typeof err === "object" && "message" in err) + return String((err as { message: string }).message); + return "上傳或檢查失敗,請稍後再試。"; +} + +export default function ImportBomUpload({ onSuccess }: Props) { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const selected = e.target.files; + if (!selected?.length) return; + const list = Array.from(selected).filter((f) => + f.name.toLowerCase().endsWith(".xlsx") + ); + setFiles(list); + setError(null); + }; + + const handleUploadAndCheck = async () => { + if (files.length === 0) { + setError("請至少選擇一個 .xlsx 檔案"); + return; + } + setLoading(true); + setError(null); + try { + const { batchId } = await uploadBomFiles(files); + const results = await checkBomFormat(batchId); + onSuccess(batchId, results, files.length); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + return ( + + + + + 選擇 BOM Excel 檔案 + + + 可多選 .xlsx 檔案,或選擇資料夾一次加入多個檔案。 + + + + + + {files.length > 0 + ? `已選 ${files.length} 個檔案` + : "未選擇"} + + + + + {loading && } + + {error && ( + setError(null)}> + {error} + + )} + + + + ); +} diff --git a/src/components/ImportBom/ImportBomWrapper.tsx b/src/components/ImportBom/ImportBomWrapper.tsx new file mode 100644 index 0000000..5434f2e --- /dev/null +++ b/src/components/ImportBom/ImportBomWrapper.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { useState } from "react"; +import Stack from "@mui/material/Stack"; +import ImportBomUpload from "./ImportBomUpload"; +import ImportBomResultForm from "./ImportBomResultForm"; +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} + + ); +} diff --git a/src/components/ImportBom/index.ts b/src/components/ImportBom/index.ts new file mode 100644 index 0000000..e4660d8 --- /dev/null +++ b/src/components/ImportBom/index.ts @@ -0,0 +1,4 @@ +export { default as ImportBomWrapper } from "./ImportBomWrapper"; +export { default as ImportBomUpload } from "./ImportBomUpload"; +export { default as ImportBomResultForm } from "./ImportBomResultForm"; +export { default } from "./ImportBomWrapper"; diff --git a/src/components/JoSearch/JoCreateFormModal.tsx b/src/components/JoSearch/JoCreateFormModal.tsx index 2d58cb7..b716e0f 100644 --- a/src/components/JoSearch/JoCreateFormModal.tsx +++ b/src/components/JoSearch/JoCreateFormModal.tsx @@ -77,7 +77,11 @@ const JoCreateFormModal: React.FC = ({ onClose() setMultiplier(1); }, [reset, onClose]) - + const duplicateLabels = useMemo(() => { + const count = new Map(); + bomCombo.forEach((b) => count.set(b.label, (count.get(b.label) ?? 0) + 1)); + return new Set(Array.from(count.entries()).filter(([, c]) => c > 1).map(([l]) => l)); + }, [bomCombo]); const handleAutoCompleteChange = useCallback( (event: SyntheticEvent, value: BomCombo, onChange: (...event: any[]) => void) => { console.log("BOM changed to:", value); @@ -235,11 +239,20 @@ const JoCreateFormModal: React.FC = ({ }} render={({ field, fieldState: { error } }) => ( { - handleAutoCompleteChange(event, value, field.onChange) - }} + disableClearable + options={bomCombo} + getOptionLabel={(option) => { + if (!option) return ""; + if (duplicateLabels.has(option.label)) { + const d = (option.description || "").trim().toUpperCase(); + const suffix = d === "WIP" ? t("WIP") : d === "FG" ? t("FG") : option.description ? t(option.description) : ""; + return suffix ? `${option.label} (${suffix})` : option.label; + } + return option.label; + }} + onChange={(event, value) => { + handleAutoCompleteChange(event, value, field.onChange); + }} onBlur={field.onBlur} renderInput={(params) => ( = ({ onSwitchToRecordTab }) =>{ const [page, setPage] = useState(0); const [selectedPickOrderId, setSelectedPickOrderId] = useState(undefined); const [selectedJobOrderId, setSelectedJobOrderId] = useState(undefined); - + type PickOrderFilter = "all" | "drink" | "other"; + const [filter, setFilter] = useState("all"); const fetchPickOrders = useCallback(async () => { setLoading(true); try { - const data = await fetchAllJoPickOrders(); + const isDrinkParam = + filter === "all" ? undefined : filter === "drink" ? true : false; + const data = await fetchAllJoPickOrders(isDrinkParam); setPickOrders(Array.isArray(data) ? data : []); setPage(0); } catch (e) { @@ -42,11 +45,11 @@ const JoPickOrderList: React.FC = ({ onSwitchToRecordTab }) =>{ } finally { setLoading(false); } - }, []); + }, [filter]); useEffect(() => { - fetchPickOrders(); - }, [fetchPickOrders]); + fetchPickOrders( ); + }, [fetchPickOrders, filter]); const handleBackToList = useCallback(() => { setSelectedPickOrderId(undefined); setSelectedJobOrderId(undefined); @@ -87,7 +90,31 @@ const JoPickOrderList: React.FC = ({ onSwitchToRecordTab }) =>{ ) : ( + + + + + + {t("Total pick orders")}: {pickOrders.length} @@ -106,6 +133,7 @@ const JoPickOrderList: React.FC = ({ onSwitchToRecordTab }) =>{ const finishedCount = pickOrder.finishedPickOLineCount ?? 0; return ( + = ({ onSelectProcess, printerCombo ,onSelectMatchingStock}) => { - const { t } = useTranslation( ["common", "production","purchaseOrder"]); + const { t } = useTranslation( ["common", "production","purchaseOrder","dashboard"]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const sessionToken = session as SessionWithTokens | null; const [loading, setLoading] = useState(false); @@ -327,6 +327,7 @@ const ProductProcessList: React.FC = ({ onSelectProcess printerCombo={printerCombo} warehouse={[]} printSource="productionProcess" + uiMode="default" /> {processes.length > 0 && (