diff --git a/src/app/(main)/settings/equipment/page.tsx b/src/app/(main)/settings/equipment/page.tsx index f55631c..3ef292d 100644 --- a/src/app/(main)/settings/equipment/page.tsx +++ b/src/app/(main)/settings/equipment/page.tsx @@ -12,6 +12,7 @@ import { Suspense } from "react"; import { fetchAllEquipments } from "@/app/api/settings/equipment"; import { I18nProvider } from "@/i18n"; import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper"; +import EquipmentSearchLoading from "@/components/EquipmentSearch/EquipmentSearchLoading"; export const metadata: Metadata = { title: "Equipment Type", @@ -33,7 +34,7 @@ const productSetting: React.FC = async () => { {t("Equipment")} - }> + }> diff --git a/src/app/api/settings/equipment/client.ts b/src/app/api/settings/equipment/client.ts new file mode 100644 index 0000000..8ab3998 --- /dev/null +++ b/src/app/api/settings/equipment/client.ts @@ -0,0 +1,33 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { EquipmentResult } from "./index"; + +export const exportEquipmentQrCode = async (equipmentIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { + + const token = localStorage.getItem("accessToken"); + + const response = await fetch(`${NEXT_PUBLIC_API_URL}/Equipment/export-qrcode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ equipmentIds }), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error("Unauthorized: Please log in again"); + } + throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`); + } + + const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "equipment_qrcode.pdf"; + + const blob = await response.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const blobValue = new Uint8Array(arrayBuffer); + + return { blobValue, filename }; +}; diff --git a/src/app/api/settings/equipmentDetail/client.ts b/src/app/api/settings/equipmentDetail/client.ts new file mode 100644 index 0000000..8627b52 --- /dev/null +++ b/src/app/api/settings/equipmentDetail/client.ts @@ -0,0 +1,33 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { EquipmentDetailResult } from "./index"; + +export const exportEquipmentQrCode = async (equipmentDetailIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { + + const token = localStorage.getItem("accessToken"); + + const response = await fetch(`${NEXT_PUBLIC_API_URL}/Equipment/export-qrcode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ equipmentDetailIds }), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error("Unauthorized: Please log in again"); + } + throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`); + } + + const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "equipment_qrcode.pdf"; + + const blob = await response.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const blobValue = new Uint8Array(arrayBuffer); + + return { blobValue, filename }; +}; diff --git a/src/app/api/settings/equipmentDetail/index.ts b/src/app/api/settings/equipmentDetail/index.ts new file mode 100644 index 0000000..393442c --- /dev/null +++ b/src/app/api/settings/equipmentDetail/index.ts @@ -0,0 +1,32 @@ +import { cache } from "react"; +import "server-only"; +import { serverFetchJson } from "../../../utils/fetchUtil"; +import { BASE_API_URL } from "../../../../config/api"; + +export type EquipmentDetailResult = { + id: string | number; + code: string; + name: string; + description: string | undefined; + equipmentCode?: string; + equipmentTypeId?: string | number | undefined; + repairAndMaintenanceStatus?: boolean | number; + latestRepairAndMaintenanceDate?: string | Date; + lastRepairAndMaintenanceDate?: string | Date; + repairAndMaintenanceRemarks?: string; +}; + +export const fetchAllEquipmentDetails = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/EquipmentDetail`, { + next: { tags: ["equipmentDetails"] }, + }); +}); + +export const fetchEquipmentDetail = cache(async (id: number) => { + return serverFetchJson( + `${BASE_API_URL}/EquipmentDetail/details/${id}`, + { + next: { tags: ["equipmentDetails"] }, + }, + ); +}); diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 23d378d..114f98c 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -17,6 +17,7 @@ const pathToLabelMap: { [path: string]: string } = { "/settings/qrCodeHandle": "QR Code Handle", "/settings/rss": "Demand Forecast Setting", "/settings/equipment": "Equipment", + "/settings/equipment/MaintenanceEdit": "MaintenanceEdit", "/settings/shop": "ShopAndTruck", "/settings/shop/detail": "Shop Detail", "/settings/shop/truckdetail": "Truck Lane Detail", diff --git a/src/components/EquipmentSearch/EquipmentSearch.tsx b/src/components/EquipmentSearch/EquipmentSearch.tsx index 735b2a8..c850014 100644 --- a/src/components/EquipmentSearch/EquipmentSearch.tsx +++ b/src/components/EquipmentSearch/EquipmentSearch.tsx @@ -1,20 +1,35 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +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, useSearchParams } from "next/navigation"; -import { GridDeleteIcon } from "@mui/x-data-grid"; -import { TypeEnum } from "@/app/utils/typeEnum"; -import axios from "axios"; +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"; type Props = { equipments: EquipmentResult[]; @@ -28,14 +43,37 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { useState([]); const { t } = useTranslation("common"); const router = useRouter(); - const [filterObj, setFilterObj] = useState({}); - const [pagingController, setPagingController] = useState({ - pageNum: 1, - pageSize: 10, + 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 [isExistingCombination, setIsExistingCombination] = useState(false); + const [loadingEquipments, setLoadingEquipments] = useState(false); + const [saving, setSaving] = useState(false); useEffect(() => { const checkReady = () => { @@ -90,20 +128,12 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { }, ]; } - + return [ - { label: t("Code"), paramName: "code", type: "text" }, - { label: t("Description"), paramName: "description", type: "text" }, + { label: "設備編號", paramName: "code", type: "text" }, ]; }, [t, tabIndex]); - const onDetailClick = useCallback( - (equipment: EquipmentResult) => { - router.push(`/settings/equipment/edit?id=${equipment.id}`); - }, - [router], - ); - const onMaintenanceEditClick = useCallback( (equipment: EquipmentResult) => { router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`); @@ -116,34 +146,226 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { [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(""); + 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(""); + } + }; + + checkAndGenerateEquipmentCode(); + }, [selectedDescription, selectedName, equipmentList]); + + 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: "id", - label: t("Details"), - onClick: onDetailClick, - buttonIcon: , - }, { name: "code", - label: t("Code"), - }, - { - name: "description", - label: t("Description"), - }, - { - name: "equipmentTypeId", - label: t("Equipment Type"), - }, - { - name: "action", - label: t(""), - buttonIcon: , - onClick: onDeleteClick, + label: "設備編號", + renderCell: (item) => ( + + { + e.stopPropagation(); + handleToggleExpand(item.id, item.code); + }} + sx={{ padding: 0.5 }} + > + {expandedRows.has(item.id) ? ( + + ) : ( + + )} + + {item.code} + + ), }, ], - [onDetailClick, onDeleteClick, t], + [t, handleToggleExpand, expandedRows], ); const repairMaintenanceColumns = useMemo[]>( @@ -250,8 +472,6 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { const transformedFilter: any = { ...filterObj }; - // For maintenance tab (tabIndex === 1), if equipmentCode is provided, - // also search by code (equipment name) with the same value if (tabIndex === 1 && transformedFilter.equipmentCode) { transformedFilter.code = transformedFilter.equipmentCode; } @@ -308,24 +528,253 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { }, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]); const onReset = useCallback(() => { - setFilterObj({}); - setPagingController({ - pageNum: 1, - pageSize: pagingController.pageSize, - }); - }, [pagingController.pageSize]); + 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 && !selectedEquipmentCode) { + 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 { + newEquipmentCode = selectedEquipmentCode; + } + } else { + if (isExistingCombination) { + newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; + } else { + newEquipmentCode = selectedEquipmentCode; + } + } + + 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, 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 ( <> { - setFilterObj({ - ...query, + setFilterObjByTab(prev => { + const newState = { ...prev }; + newState[tabIndex] = query as unknown as SearchQuery; + return newState; }); }} onReset={onReset} /> + {tabIndex === 0 && ( + + + 設備編號 + + + + )} = ({ equipments, tabIndex = 0 }) => { items={filteredEquipments} columns={columns} - setPagingController={setPagingController} + 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 && ( + + + 確認刪除 + + + + 您確定要刪除此設備詳細資料嗎?此操作無法復原。 + + + + + + + + )} -export default EquipmentSearch; \ No newline at end of file + {/* 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) { + setSelectedEquipmentCode(e.target.value); + } + }} + disabled={isExistingCombination || loadingEquipments || saving} + placeholder={isExistingCombination ? "自動生成" : "輸入設備編號"} + sx={{ mt: 2 }} + required={!isExistingCombination} + /> + + + + + + + + + ); + }; + + export default EquipmentSearch; \ No newline at end of file diff --git a/src/components/EquipmentSearch/EquipmentSearchLoading.tsx b/src/components/EquipmentSearch/EquipmentSearchLoading.tsx index 838189b..100feb0 100644 --- a/src/components/EquipmentSearch/EquipmentSearchLoading.tsx +++ b/src/components/EquipmentSearch/EquipmentSearchLoading.tsx @@ -1,3 +1,5 @@ +"use client"; + import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Skeleton from "@mui/material/Skeleton"; diff --git a/src/components/EquipmentSearch/EquipmentSearchResults.tsx b/src/components/EquipmentSearch/EquipmentSearchResults.tsx index 7f84a41..d35b83b 100644 --- a/src/components/EquipmentSearch/EquipmentSearchResults.tsx +++ b/src/components/EquipmentSearch/EquipmentSearchResults.tsx @@ -48,6 +48,7 @@ interface BaseColumn { style?: Partial & { [propName: string]: string }; type?: ColumnType; renderCell?: (params: T) => React.ReactNode; + renderHeader?: () => React.ReactNode; } interface IconColumn extends BaseColumn { @@ -104,6 +105,8 @@ interface Props { checkboxIds?: (string | number)[]; setCheckboxIds?: Dispatch>; onRowClick?: (item: T) => void; + renderExpandedRow?: (item: T) => React.ReactNode; + hideHeader?: boolean; } function isActionColumn( @@ -197,6 +200,8 @@ function EquipmentSearchResults({ checkboxIds = [], setCheckboxIds = undefined, onRowClick = undefined, + renderExpandedRow = undefined, + hideHeader = false, }: Props) { const { t } = useTranslation("common"); const [page, setPage] = React.useState(0); @@ -303,35 +308,41 @@ function EquipmentSearchResults({ const table = ( <> - - - - {columns.map((column, idx) => ( - isCheckboxColumn(column) ? - - 0 && currItemsWithChecked.length < currItems.length} - checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} - onChange={handleSelectAllClick} - /> - - : - {column.label.split('\n').map((line, index) => ( -
{line}
// Render each line in a div - ))} -
- ))} -
-
+
+ {!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}
// Render each line in a div + )) + )} +
+ ))} +
+
+ )} {isAutoPaging ? items @@ -339,10 +350,45 @@ function EquipmentSearchResults({ (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) @@ -370,38 +416,8 @@ function EquipmentSearchResults({ ); })} - ); - }) - : 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)} + ); })} diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index f4722ae..0387d8a 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -306,7 +306,7 @@ function SearchBox({ {t("All")} {c.options.map((option) => ( diff --git a/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx b/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx index 87d5df1..212a28e 100644 --- a/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx +++ b/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx @@ -1,84 +1,102 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; import SearchBox, { Criterion } from "../SearchBox"; -import { EquipmentResult } from "@/app/api/settings/equipment"; +import { useCallback, useMemo, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; -import { EditNote } from "@mui/icons-material"; import { useRouter } from "next/navigation"; -import { GridDeleteIcon } from "@mui/x-data-grid"; +import { successDialog } from "../Swal/CustomAlerts"; +import useUploadContext from "../UploadProvider/useUploadContext"; +import { downloadFile } from "@/app/utils/commonUtil"; +import { EquipmentDetailResult } from "@/app/api/settings/equipmentDetail"; +import { exportEquipmentQrCode } from "@/app/api/settings/equipmentDetail/client"; +import { + Checkbox, + Box, + Button, + TextField, + Stack, + Autocomplete, + Modal, + Card, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Typography +} from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; +import PrintIcon from "@mui/icons-material/Print"; +import CloseIcon from "@mui/icons-material/Close"; +import { PrinterCombo } from "@/app/api/settings/printer"; import axiosInstance from "@/app/(main)/axios/axiosInstance"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; -type Props = { - equipments: EquipmentResult[]; -}; +interface Props { + equipmentDetails: EquipmentDetailResult[]; + printerCombo: PrinterCombo[]; +} -type SearchQuery = Partial>; +type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { - const [filteredEquipments, setFilteredEquipments] = - useState([]); +const QrCodeHandleEquipmentSearch: React.FC = ({ equipmentDetails, printerCombo }) => { const { t } = useTranslation("common"); + const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState([]); const router = useRouter(); - const [filterObj, setFilterObj] = useState({}); + const { setIsUploading } = useUploadContext(); const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10, }); + const [filterObj, setFilterObj] = useState({}); const [totalCount, setTotalCount] = useState(0); - - const searchCriteria: Criterion[] = useMemo(() => { - const searchCriteria: Criterion[] = [ - { label: t("Code"), paramName: "code", type: "text" }, - { label: t("Description"), paramName: "description", type: "text" }, - ]; - return searchCriteria; - }, [t, equipments]); - - const onDetailClick = useCallback( - (equipment: EquipmentResult) => { - router.push(`/settings/equipment/edit?id=${equipment.id}`); - }, - [router], - ); - const onDeleteClick = useCallback( - (equipment: EquipmentResult) => {}, - [router], + const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]); + const [selectedEquipmentDetailsMap, setSelectedEquipmentDetailsMap] = useState>(new Map()); + const [selectAll, setSelectAll] = useState(false); + const [printQty, setPrintQty] = useState(1); + const [isSearching, setIsSearching] = useState(false); + + const [previewOpen, setPreviewOpen] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + + const [selectedEquipmentDetailsModalOpen, setSelectedEquipmentDetailsModalOpen] = useState(false); + + const filteredPrinters = useMemo(() => { + return printerCombo.filter((printer) => { + return printer.type === "A4"; + }); + }, [printerCombo]); + + const [selectedPrinter, setSelectedPrinter] = useState( + filteredPrinters.length > 0 ? filteredPrinters[0] : undefined ); - const columns = useMemo[]>( + useEffect(() => { + if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) { + setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined); + } + }, [filteredPrinters, selectedPrinter]); + + const searchCriteria: Criterion[] = useMemo( () => [ { - name: "id", - label: t("Details"), - onClick: onDetailClick, - buttonIcon: , - }, - { - name: "code", - label: t("Code"), - }, - { - name: "equipmentTypeId", - label: t("Equipment Type"), - sx: {minWidth: 180}, - }, - { - name: "description", - label: t("Description"), + label: "設備名稱", + paramName: "code", + type: "text", }, { - name: "action", - label: t(""), - buttonIcon: , - onClick: onDeleteClick, + label: "設備編號", + paramName: "equipmentCode", + type: "text", }, ], - [filteredEquipments], + [], ); interface ApiResponse { @@ -101,20 +119,19 @@ const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { ...filterObj, }; try { - const response = await axiosInstance.get>( - `${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, + const response = await axiosInstance.get>( + `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, { params }, ); - console.log(response); if (response.status == 200) { - setFilteredEquipments(response.data.records); + setFilteredEquipmentDetails(response.data.records); setTotalCount(response.data.total); return response; } else { throw "400"; } } catch (error) { - console.error("Error fetching equipment types:", error); + console.error("Error fetching equipment details:", error); throw error; } }, @@ -125,6 +142,228 @@ const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { refetchData(filterObj, pagingController.pageNum, pagingController.pageSize); }, [filterObj, pagingController.pageNum, pagingController.pageSize]); + useEffect(() => { + if (filteredEquipmentDetails.length > 0) { + const allCurrentPageSelected = filteredEquipmentDetails.every(ed => checkboxIds.includes(ed.id)); + setSelectAll(allCurrentPageSelected); + } else { + setSelectAll(false); + } + }, [filteredEquipmentDetails, checkboxIds]); + + const handleSelectEquipmentDetail = useCallback((equipmentDetailId: string | number, checked: boolean) => { + if (checked) { + const equipmentDetail = filteredEquipmentDetails.find(ed => ed.id === equipmentDetailId); + if (equipmentDetail) { + setCheckboxIds(prev => [...prev, equipmentDetailId]); + setSelectedEquipmentDetailsMap(prev => { + const newMap = new Map(prev); + newMap.set(equipmentDetailId, equipmentDetail); + return newMap; + }); + } + } else { + setCheckboxIds(prev => prev.filter(id => id !== equipmentDetailId)); + setSelectedEquipmentDetailsMap(prev => { + const newMap = new Map(prev); + newMap.delete(equipmentDetailId); + return newMap; + }); + setSelectAll(false); + } + }, [filteredEquipmentDetails]); + + const fetchAllMatchingEquipmentDetails = useCallback(async (): Promise => { + const authHeader = axiosInstance.defaults.headers["Authorization"]; + if (!authHeader) { + return []; + } + + if (totalCount === 0) { + return []; + } + + const params = { + pageNum: 1, + pageSize: totalCount > 0 ? totalCount : 10000, + ...filterObj, + }; + try { + const response = await axiosInstance.get>( + `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, + { params }, + ); + if (response.status == 200) { + return response.data.records; + } + return []; + } catch (error) { + console.error("Error fetching all equipment details:", error); + return []; + } + }, [filterObj, totalCount]); + + const handleSelectAll = useCallback(async (checked: boolean) => { + if (checked) { + try { + const allEquipmentDetails = await fetchAllMatchingEquipmentDetails(); + const allIds = allEquipmentDetails.map(equipmentDetail => equipmentDetail.id); + + setCheckboxIds(allIds); + setSelectedEquipmentDetailsMap(prev => { + const newMap = new Map(prev); + allEquipmentDetails.forEach(equipmentDetail => { + newMap.set(equipmentDetail.id, equipmentDetail); + }); + return newMap; + }); + setSelectAll(true); + } catch (error) { + console.error("Error selecting all equipment:", error); + } + } else { + setCheckboxIds([]); + setSelectedEquipmentDetailsMap(new Map()); + setSelectAll(false); + } + }, [fetchAllMatchingEquipmentDetails]); + + const showPdfPreview = useCallback(async (equipmentDetailIds: (string | number)[]) => { + if (equipmentDetailIds.length === 0) { + return; + } + try { + setIsUploading(true); + const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id); + const response = await exportEquipmentQrCode(numericIds); + + const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + + setPreviewUrl(`${url}#toolbar=0`); + setPreviewOpen(true); + } catch (error) { + console.error("Error exporting QR code:", error); + } finally { + setIsUploading(false); + } + }, [setIsUploading]); + + const handleClosePreview = useCallback(() => { + setPreviewOpen(false); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + } + }, [previewUrl]); + + const handleDownloadQrCode = useCallback(async (equipmentDetailIds: (string | number)[]) => { + if (equipmentDetailIds.length === 0) { + return; + } + try { + setIsUploading(true); + const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id); + const response = await exportEquipmentQrCode(numericIds); + downloadFile(response.blobValue, response.filename); + setSelectedEquipmentDetailsModalOpen(false); + successDialog("二維碼已下載", t); + } catch (error) { + console.error("Error exporting QR code:", error); + } finally { + setIsUploading(false); + } + }, [setIsUploading, t]); + + const handlePrint = useCallback(async () => { + if (checkboxIds.length === 0) { + return; + } + try { + setIsUploading(true); + const numericIds = checkboxIds.map(id => typeof id === 'string' ? parseInt(id) : id); + const response = await exportEquipmentQrCode(numericIds); + + const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + + const printWindow = window.open(url, '_blank'); + if (printWindow) { + printWindow.onload = () => { + for (let i = 0; i < printQty; i++) { + setTimeout(() => { + printWindow.print(); + }, i * 500); + } + }; + } + + setTimeout(() => { + URL.revokeObjectURL(url); + }, 1000); + setSelectedEquipmentDetailsModalOpen(false); + successDialog("二維碼已列印", t); + } catch (error) { + console.error("Error printing QR code:", error); + } finally { + setIsUploading(false); + } + }, [checkboxIds, printQty, setIsUploading, t]); + + const handleViewSelectedQrCodes = useCallback(() => { + if (checkboxIds.length === 0) { + return; + } + setSelectedEquipmentDetailsModalOpen(true); + }, [checkboxIds]); + + const selectedEquipmentDetails = useMemo(() => { + return Array.from(selectedEquipmentDetailsMap.values()); + }, [selectedEquipmentDetailsMap]); + + const handleCloseSelectedEquipmentDetailsModal = useCallback(() => { + setSelectedEquipmentDetailsModalOpen(false); + }, []); + + const columns = useMemo[]>( + () => [ + { + name: "id", + label: "", + sx: { width: "50px", minWidth: "50px" }, + renderCell: (params) => ( + handleSelectEquipmentDetail(params.id, e.target.checked)} + onClick={(e) => e.stopPropagation()} + /> + ), + }, + { + name: "code", + label: "設備名稱", + align: "left", + headerAlign: "left", + sx: { width: "150px", minWidth: "150px" }, + }, + { + name: "description", + label: "設備描述", + align: "left", + headerAlign: "left", + sx: { width: "200px", minWidth: "200px" }, + }, + { + name: "equipmentCode", + label: "設備編號", + align: "left", + headerAlign: "left", + sx: { width: "150px", minWidth: "150px" }, + }, + ], + [t, checkboxIds, handleSelectEquipmentDetail], + ); + const onReset = useCallback(() => { setFilterObj({}); setPagingController({ pageNum: 1, pageSize: 10 }); @@ -138,19 +377,238 @@ const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { setFilterObj({ ...query, }); + setPagingController({ pageNum: 1, pageSize: 10 }); }} onReset={onReset} /> - - items={filteredEquipments} + + items={filteredEquipmentDetails} columns={columns} - setPagingController={setPagingController} pagingController={pagingController} + setPagingController={setPagingController} totalCount={totalCount} isAutoPaging={false} /> + + + + + + + + + + 已選擇設備 ({selectedEquipmentDetails.length}) + + + + + + + + +
+ + + + 設備名稱 + + + 設備描述 + + + 設備編號 + + + + + {selectedEquipmentDetails.length === 0 ? ( + + + 沒有選擇的設備 + + + ) : ( + selectedEquipmentDetails.map((equipmentDetail) => ( + + {equipmentDetail.code || '-'} + {equipmentDetail.description || '-'} + {equipmentDetail.equipmentCode || '-'} + + )) + )} + +
+
+ + + + + + options={filteredPrinters} + value={selectedPrinter ?? null} + onChange={(event, value) => { + setSelectedPrinter(value ?? undefined); + }} + getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)} + renderInput={(params) => ( + + )} + /> + { + const value = parseInt(e.target.value) || 1; + setPrintQty(Math.max(1, value)); + }} + inputProps={{ min: 1 }} + sx={{ width: 120 }} + /> + + + + + + + + + + + + + + + + + {previewUrl && ( +