| @@ -12,6 +12,7 @@ import { Suspense } from "react"; | |||||
| import { fetchAllEquipments } from "@/app/api/settings/equipment"; | import { fetchAllEquipments } from "@/app/api/settings/equipment"; | ||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper"; | import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper"; | ||||
| import EquipmentSearchLoading from "@/components/EquipmentSearch/EquipmentSearchLoading"; | |||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| title: "Equipment Type", | title: "Equipment Type", | ||||
| @@ -33,7 +34,7 @@ const productSetting: React.FC = async () => { | |||||
| {t("Equipment")} | {t("Equipment")} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| <Suspense fallback={<EquipmentSearchWrapper.Loading />}> | |||||
| <Suspense fallback={<EquipmentSearchLoading />}> | |||||
| <I18nProvider namespaces={["common", "project"]}> | <I18nProvider namespaces={["common", "project"]}> | ||||
| <EquipmentSearchWrapper /> | <EquipmentSearchWrapper /> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| @@ -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 }; | |||||
| }; | |||||
| @@ -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 }; | |||||
| }; | |||||
| @@ -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<EquipmentDetailResult[]>(`${BASE_API_URL}/EquipmentDetail`, { | |||||
| next: { tags: ["equipmentDetails"] }, | |||||
| }); | |||||
| }); | |||||
| export const fetchEquipmentDetail = cache(async (id: number) => { | |||||
| return serverFetchJson<EquipmentDetailResult>( | |||||
| `${BASE_API_URL}/EquipmentDetail/details/${id}`, | |||||
| { | |||||
| next: { tags: ["equipmentDetails"] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| @@ -17,6 +17,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/settings/qrCodeHandle": "QR Code Handle", | "/settings/qrCodeHandle": "QR Code Handle", | ||||
| "/settings/rss": "Demand Forecast Setting", | "/settings/rss": "Demand Forecast Setting", | ||||
| "/settings/equipment": "Equipment", | "/settings/equipment": "Equipment", | ||||
| "/settings/equipment/MaintenanceEdit": "MaintenanceEdit", | |||||
| "/settings/shop": "ShopAndTruck", | "/settings/shop": "ShopAndTruck", | ||||
| "/settings/shop/detail": "Shop Detail", | "/settings/shop/detail": "Shop Detail", | ||||
| "/settings/shop/truckdetail": "Truck Lane Detail", | "/settings/shop/truckdetail": "Truck Lane Detail", | ||||
| @@ -1,20 +1,35 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { EquipmentResult } from "@/app/api/settings/equipment"; | import { EquipmentResult } from "@/app/api/settings/equipment"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; | import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; | ||||
| import { EditNote } from "@mui/icons-material"; | 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 { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | import axiosInstance from "@/app/(main)/axios/axiosInstance"; | ||||
| import { arrayToDateTimeString } from "@/app/utils/formatUtil"; | import { arrayToDateTimeString } from "@/app/utils/formatUtil"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import Typography from "@mui/material/Typography"; | 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 = { | type Props = { | ||||
| equipments: EquipmentResult[]; | equipments: EquipmentResult[]; | ||||
| @@ -28,14 +43,37 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| useState<EquipmentResult[]>([]); | useState<EquipmentResult[]>([]); | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const [filterObj, setFilterObj] = useState({}); | |||||
| const [pagingController, setPagingController] = useState({ | |||||
| pageNum: 1, | |||||
| pageSize: 10, | |||||
| const [filterObjByTab, setFilterObjByTab] = useState<Record<number, SearchQuery>>({ | |||||
| 0: {}, | |||||
| 1: {}, | |||||
| }); | |||||
| const [pagingControllerByTab, setPagingControllerByTab] = useState<Record<number, { pageNum: number; pageSize: number }>>({ | |||||
| 0: { pageNum: 1, pageSize: 10 }, | |||||
| 1: { pageNum: 1, pageSize: 10 }, | |||||
| }); | }); | ||||
| const [totalCount, setTotalCount] = useState(0); | const [totalCount, setTotalCount] = useState(0); | ||||
| const [isLoading, setIsLoading] = useState(true); | const [isLoading, setIsLoading] = useState(true); | ||||
| const [isReady, setIsReady] = useState(false); | const [isReady, setIsReady] = useState(false); | ||||
| const filterObj = filterObjByTab[tabIndex] || {}; | |||||
| const pagingController = pagingControllerByTab[tabIndex] || { pageNum: 1, pageSize: 10 }; | |||||
| const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set()); | |||||
| const [equipmentDetailsMap, setEquipmentDetailsMap] = useState<Map<string | number, EquipmentResult[]>>(new Map()); | |||||
| const [loadingDetailsMap, setLoadingDetailsMap] = useState<Map<string | number, boolean>>(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<EquipmentResult[]>([]); | |||||
| const [selectedDescription, setSelectedDescription] = useState<string>(""); | |||||
| const [selectedName, setSelectedName] = useState<string>(""); | |||||
| const [selectedEquipmentCode, setSelectedEquipmentCode] = useState<string>(""); | |||||
| const [isExistingCombination, setIsExistingCombination] = useState(false); | |||||
| const [loadingEquipments, setLoadingEquipments] = useState(false); | |||||
| const [saving, setSaving] = useState(false); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const checkReady = () => { | const checkReady = () => { | ||||
| @@ -90,20 +128,12 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| } | } | ||||
| return [ | return [ | ||||
| { label: t("Code"), paramName: "code", type: "text" }, | |||||
| { label: t("Description"), paramName: "description", type: "text" }, | |||||
| { label: "設備編號", paramName: "code", type: "text" }, | |||||
| ]; | ]; | ||||
| }, [t, tabIndex]); | }, [t, tabIndex]); | ||||
| const onDetailClick = useCallback( | |||||
| (equipment: EquipmentResult) => { | |||||
| router.push(`/settings/equipment/edit?id=${equipment.id}`); | |||||
| }, | |||||
| [router], | |||||
| ); | |||||
| const onMaintenanceEditClick = useCallback( | const onMaintenanceEditClick = useCallback( | ||||
| (equipment: EquipmentResult) => { | (equipment: EquipmentResult) => { | ||||
| router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`); | router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`); | ||||
| @@ -116,34 +146,226 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| [router], | [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<Column<EquipmentResult>[]>( | const generalDataColumns = useMemo<Column<EquipmentResult>[]>( | ||||
| () => [ | () => [ | ||||
| { | |||||
| name: "id", | |||||
| label: t("Details"), | |||||
| onClick: onDetailClick, | |||||
| buttonIcon: <EditNote />, | |||||
| }, | |||||
| { | { | ||||
| name: "code", | name: "code", | ||||
| label: t("Code"), | |||||
| }, | |||||
| { | |||||
| name: "description", | |||||
| label: t("Description"), | |||||
| }, | |||||
| { | |||||
| name: "equipmentTypeId", | |||||
| label: t("Equipment Type"), | |||||
| }, | |||||
| { | |||||
| name: "action", | |||||
| label: t(""), | |||||
| buttonIcon: <GridDeleteIcon />, | |||||
| onClick: onDeleteClick, | |||||
| label: "設備編號", | |||||
| renderCell: (item) => ( | |||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleToggleExpand(item.id, item.code); | |||||
| }} | |||||
| sx={{ padding: 0.5 }} | |||||
| > | |||||
| {expandedRows.has(item.id) ? ( | |||||
| <KeyboardArrowUpIcon fontSize="small" /> | |||||
| ) : ( | |||||
| <KeyboardArrowDownIcon fontSize="small" /> | |||||
| )} | |||||
| </IconButton> | |||||
| <Typography>{item.code}</Typography> | |||||
| </Box> | |||||
| ), | |||||
| }, | }, | ||||
| ], | ], | ||||
| [onDetailClick, onDeleteClick, t], | |||||
| [t, handleToggleExpand, expandedRows], | |||||
| ); | ); | ||||
| const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>( | const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>( | ||||
| @@ -250,8 +472,6 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| const transformedFilter: any = { ...filterObj }; | 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) { | if (tabIndex === 1 && transformedFilter.equipmentCode) { | ||||
| transformedFilter.code = transformedFilter.equipmentCode; | transformedFilter.code = transformedFilter.equipmentCode; | ||||
| } | } | ||||
| @@ -308,24 +528,253 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| }, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]); | }, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]); | ||||
| const onReset = useCallback(() => { | 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<EquipmentResult>( | |||||
| `${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<EquipmentResult>( | |||||
| `${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 ( | |||||
| <TableRow key={`expanded-${item.id}`}> | |||||
| <TableCell colSpan={columns.length} sx={{ py: 0, border: 0 }}> | |||||
| <Collapse in={expandedRows.has(item.id)} timeout="auto" unmountOnExit> | |||||
| <Box sx={{ margin: 2 }}> | |||||
| {isLoading ? ( | |||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 2, p: 2 }}> | |||||
| <CircularProgress size={20} /> | |||||
| <Typography>載入中...</Typography> | |||||
| </Box> | |||||
| ) : details.length === 0 ? ( | |||||
| <Typography sx={{ p: 2 }}>無相關設備詳細資料</Typography> | |||||
| ) : ( | |||||
| <Box> | |||||
| <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: "bold" }}> | |||||
| 設備詳細資料 (設備編號: {item.code}) | |||||
| </Typography> | |||||
| <Grid container spacing={2}> | |||||
| {details.map((detail) => ( | |||||
| <Grid item xs={6} key={detail.id}> | |||||
| <Box | |||||
| sx={{ | |||||
| p: 2, | |||||
| border: "1px solid", | |||||
| borderColor: "divider", | |||||
| borderRadius: 1, | |||||
| height: "100%", | |||||
| position: "relative", | |||||
| }} | |||||
| > | |||||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 1 }}> | |||||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | |||||
| 編號: {detail.code || "-"} | |||||
| </Typography> | |||||
| <IconButton | |||||
| size="small" | |||||
| color="error" | |||||
| onClick={() => handleDeleteClick(detail.id, item.id)} | |||||
| sx={{ ml: 1 }} | |||||
| > | |||||
| <DeleteIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| {detail.name && ( | |||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | |||||
| 名稱: {detail.name} | |||||
| </Typography> | |||||
| )} | |||||
| {detail.description && ( | |||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | |||||
| 描述: {detail.description} | |||||
| </Typography> | |||||
| )} | |||||
| {detail.equipmentCode && ( | |||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | |||||
| 設備編號: {detail.equipmentCode} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| </Grid> | |||||
| ))} | |||||
| </Grid> | |||||
| </Box> | |||||
| )} | |||||
| </Box> | |||||
| </Collapse> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={(query) => { | onSearch={(query) => { | ||||
| setFilterObj({ | |||||
| ...query, | |||||
| setFilterObjByTab(prev => { | |||||
| const newState = { ...prev }; | |||||
| newState[tabIndex] = query as unknown as SearchQuery; | |||||
| return newState; | |||||
| }); | }); | ||||
| }} | }} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| /> | /> | ||||
| {tabIndex === 0 && ( | |||||
| <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}> | |||||
| <Typography variant="h6" component="h2"> | |||||
| 設備編號 | |||||
| </Typography> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<AddIcon />} | |||||
| onClick={handleAddClick} | |||||
| color="primary" | |||||
| > | |||||
| 新增 | |||||
| </Button> | |||||
| </Box> | |||||
| )} | |||||
| <Box sx={{ | <Box sx={{ | ||||
| "& .MuiTableContainer-root": { | "& .MuiTableContainer-root": { | ||||
| overflowY: "auto", | overflowY: "auto", | ||||
| @@ -337,14 +786,150 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| <EquipmentSearchResults<EquipmentResult> | <EquipmentSearchResults<EquipmentResult> | ||||
| items={filteredEquipments} | items={filteredEquipments} | ||||
| columns={columns} | 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} | pagingController={pagingController} | ||||
| totalCount={totalCount} | totalCount={totalCount} | ||||
| isAutoPaging={false} | isAutoPaging={false} | ||||
| /> | |||||
| </Box> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| renderExpandedRow={renderExpandedRow} | |||||
| hideHeader={tabIndex === 0} | |||||
| /> | |||||
| </Box> | |||||
| {/* Delete Confirmation Dialog */} | |||||
| {deleteDialogOpen && ( | |||||
| <Dialog | |||||
| open={deleteDialogOpen} | |||||
| onClose={handleDeleteCancel} | |||||
| aria-labelledby="delete-dialog-title" | |||||
| aria-describedby="delete-dialog-description" | |||||
| > | |||||
| <DialogTitle id="delete-dialog-title"> | |||||
| 確認刪除 | |||||
| </DialogTitle> | |||||
| <DialogContent> | |||||
| <DialogContentText id="delete-dialog-description"> | |||||
| 您確定要刪除此設備詳細資料嗎?此操作無法復原。 | |||||
| </DialogContentText> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleDeleteCancel} disabled={deleting}> | |||||
| 取消 | |||||
| </Button> | |||||
| <Button onClick={handleDeleteConfirm} color="error" disabled={deleting} autoFocus> | |||||
| {deleting ? "刪除中..." : "刪除"} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| )} | |||||
| export default EquipmentSearch; | |||||
| {/* Add Equipment Detail Dialog */} | |||||
| <Dialog | |||||
| open={addDialogOpen} | |||||
| onClose={handleAddDialogClose} | |||||
| aria-labelledby="add-dialog-title" | |||||
| maxWidth="sm" | |||||
| fullWidth | |||||
| > | |||||
| <DialogTitle id="add-dialog-title"> | |||||
| 新增設備詳細資料 | |||||
| </DialogTitle> | |||||
| <DialogContent> | |||||
| <Box sx={{ pt: 2 }}> | |||||
| <Autocomplete | |||||
| freeSolo | |||||
| options={availableDescriptions} | |||||
| value={selectedDescription || null} | |||||
| onChange={(event, newValue) => { | |||||
| setSelectedDescription(newValue || ''); | |||||
| }} | |||||
| onInputChange={(event, newInputValue) => { | |||||
| setSelectedDescription(newInputValue); | |||||
| }} | |||||
| loading={loadingEquipments} | |||||
| disabled={loadingEquipments || saving} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| label="種類" | |||||
| placeholder="選擇或輸入種類" | |||||
| /> | |||||
| )} | |||||
| sx={{ mb: 2 }} | |||||
| /> | |||||
| <Autocomplete | |||||
| freeSolo | |||||
| options={availableNames} | |||||
| value={selectedName || null} | |||||
| onChange={(event, newValue) => { | |||||
| 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) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| label="名稱" | |||||
| placeholder="選擇或輸入名稱" | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| <TextField | |||||
| fullWidth | |||||
| label="設備編號" | |||||
| value={selectedEquipmentCode} | |||||
| onChange={(e) => { | |||||
| if (!isExistingCombination) { | |||||
| setSelectedEquipmentCode(e.target.value); | |||||
| } | |||||
| }} | |||||
| disabled={isExistingCombination || loadingEquipments || saving} | |||||
| placeholder={isExistingCombination ? "自動生成" : "輸入設備編號"} | |||||
| sx={{ mt: 2 }} | |||||
| required={!isExistingCombination} | |||||
| /> | |||||
| </Box> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleAddDialogClose} disabled={saving}> | |||||
| 取消 | |||||
| </Button> | |||||
| <Button | |||||
| onClick={handleSaveEquipmentDetail} | |||||
| variant="contained" | |||||
| disabled={!selectedName || !selectedDescription || (!isExistingCombination && !selectedEquipmentCode) || loadingEquipments || saving} | |||||
| > | |||||
| {saving ? "保存中..." : "新增"} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default EquipmentSearch; | |||||
| @@ -1,3 +1,5 @@ | |||||
| "use client"; | |||||
| import Card from "@mui/material/Card"; | import Card from "@mui/material/Card"; | ||||
| import CardContent from "@mui/material/CardContent"; | import CardContent from "@mui/material/CardContent"; | ||||
| import Skeleton from "@mui/material/Skeleton"; | import Skeleton from "@mui/material/Skeleton"; | ||||
| @@ -48,6 +48,7 @@ interface BaseColumn<T extends ResultWithId> { | |||||
| style?: Partial<HTMLElement["style"]> & { [propName: string]: string }; | style?: Partial<HTMLElement["style"]> & { [propName: string]: string }; | ||||
| type?: ColumnType; | type?: ColumnType; | ||||
| renderCell?: (params: T) => React.ReactNode; | renderCell?: (params: T) => React.ReactNode; | ||||
| renderHeader?: () => React.ReactNode; | |||||
| } | } | ||||
| interface IconColumn<T extends ResultWithId> extends BaseColumn<T> { | interface IconColumn<T extends ResultWithId> extends BaseColumn<T> { | ||||
| @@ -104,6 +105,8 @@ interface Props<T extends ResultWithId> { | |||||
| checkboxIds?: (string | number)[]; | checkboxIds?: (string | number)[]; | ||||
| setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | ||||
| onRowClick?: (item: T) => void; | onRowClick?: (item: T) => void; | ||||
| renderExpandedRow?: (item: T) => React.ReactNode; | |||||
| hideHeader?: boolean; | |||||
| } | } | ||||
| function isActionColumn<T extends ResultWithId>( | function isActionColumn<T extends ResultWithId>( | ||||
| @@ -197,6 +200,8 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| checkboxIds = [], | checkboxIds = [], | ||||
| setCheckboxIds = undefined, | setCheckboxIds = undefined, | ||||
| onRowClick = undefined, | onRowClick = undefined, | ||||
| renderExpandedRow = undefined, | |||||
| hideHeader = false, | |||||
| }: Props<T>) { | }: Props<T>) { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [page, setPage] = React.useState(0); | const [page, setPage] = React.useState(0); | ||||
| @@ -303,35 +308,41 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| const table = ( | const table = ( | ||||
| <> | <> | ||||
| <TableContainer sx={{ maxHeight: 440 }}> | <TableContainer sx={{ maxHeight: 440 }}> | ||||
| <Table stickyHeader> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| {columns.map((column, idx) => ( | |||||
| isCheckboxColumn(column) ? | |||||
| <TableCell | |||||
| align={column.headerAlign} | |||||
| sx={column.sx} | |||||
| key={`${column.name.toString()}${idx}`} | |||||
| > | |||||
| <Checkbox | |||||
| color="primary" | |||||
| indeterminate={currItemsWithChecked.length > 0 && currItemsWithChecked.length < currItems.length} | |||||
| checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} | |||||
| onChange={handleSelectAllClick} | |||||
| /> | |||||
| </TableCell> | |||||
| : <TableCell | |||||
| align={column.headerAlign} | |||||
| sx={column.sx} | |||||
| key={`${column.name.toString()}${idx}`} | |||||
| > | |||||
| {column.label.split('\n').map((line, index) => ( | |||||
| <div key={index}>{line}</div> // Render each line in a div | |||||
| ))} | |||||
| </TableCell> | |||||
| ))} | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <Table stickyHeader={!hideHeader}> | |||||
| {!hideHeader && ( | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| {columns.map((column, idx) => ( | |||||
| isCheckboxColumn(column) ? | |||||
| <TableCell | |||||
| align={column.headerAlign} | |||||
| sx={column.sx} | |||||
| key={`${column.name.toString()}${idx}`} | |||||
| > | |||||
| <Checkbox | |||||
| color="primary" | |||||
| indeterminate={currItemsWithChecked.length > 0 && currItemsWithChecked.length < currItems.length} | |||||
| checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} | |||||
| onChange={handleSelectAllClick} | |||||
| /> | |||||
| </TableCell> | |||||
| : <TableCell | |||||
| align={column.headerAlign} | |||||
| sx={column.sx} | |||||
| key={`${column.name.toString()}${idx}`} | |||||
| > | |||||
| {column.renderHeader ? ( | |||||
| column.renderHeader() | |||||
| ) : ( | |||||
| column.label.split('\n').map((line, index) => ( | |||||
| <div key={index}>{line}</div> // Render each line in a div | |||||
| )) | |||||
| )} | |||||
| </TableCell> | |||||
| ))} | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| )} | |||||
| <TableBody> | <TableBody> | ||||
| {isAutoPaging | {isAutoPaging | ||||
| ? items | ? items | ||||
| @@ -339,10 +350,45 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| (pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage)) | (pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage)) | ||||
| .map((item) => { | .map((item) => { | ||||
| return ( | return ( | ||||
| <TableRow | |||||
| hover | |||||
| tabIndex={-1} | |||||
| key={item.id} | |||||
| <React.Fragment key={item.id}> | |||||
| <TableRow | |||||
| hover | |||||
| tabIndex={-1} | |||||
| onClick={(event) => { | |||||
| setCheckboxIds | |||||
| ? handleRowClick(event, item, columns) | |||||
| : undefined | |||||
| if (onRowClick) { | |||||
| onRowClick(item) | |||||
| } | |||||
| } | |||||
| } | |||||
| role={setCheckboxIds ? "checkbox" : undefined} | |||||
| > | |||||
| {columns.map((column, idx) => { | |||||
| const columnName = column.name; | |||||
| return ( | |||||
| <TabelCells | |||||
| key={`${columnName.toString()}-${idx}`} | |||||
| column={column} | |||||
| columnName={columnName} | |||||
| idx={idx} | |||||
| item={item} | |||||
| checkboxIds={checkboxIds} | |||||
| /> | |||||
| ); | |||||
| })} | |||||
| </TableRow> | |||||
| {renderExpandedRow && renderExpandedRow(item)} | |||||
| </React.Fragment> | |||||
| ); | |||||
| }) | |||||
| : items.map((item) => { | |||||
| return ( | |||||
| <React.Fragment key={item.id}> | |||||
| <TableRow hover tabIndex={-1} | |||||
| onClick={(event) => { | onClick={(event) => { | ||||
| setCheckboxIds | setCheckboxIds | ||||
| ? handleRowClick(event, item, columns) | ? handleRowClick(event, item, columns) | ||||
| @@ -370,38 +416,8 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| ); | ); | ||||
| })} | })} | ||||
| </TableRow> | </TableRow> | ||||
| ); | |||||
| }) | |||||
| : items.map((item) => { | |||||
| return ( | |||||
| <TableRow hover tabIndex={-1} key={item.id} | |||||
| onClick={(event) => { | |||||
| setCheckboxIds | |||||
| ? handleRowClick(event, item, columns) | |||||
| : undefined | |||||
| if (onRowClick) { | |||||
| onRowClick(item) | |||||
| } | |||||
| } | |||||
| } | |||||
| role={setCheckboxIds ? "checkbox" : undefined} | |||||
| > | |||||
| {columns.map((column, idx) => { | |||||
| const columnName = column.name; | |||||
| return ( | |||||
| <TabelCells | |||||
| key={`${columnName.toString()}-${idx}`} | |||||
| column={column} | |||||
| columnName={columnName} | |||||
| idx={idx} | |||||
| item={item} | |||||
| checkboxIds={checkboxIds} | |||||
| /> | |||||
| ); | |||||
| })} | |||||
| </TableRow> | |||||
| {renderExpandedRow && renderExpandedRow(item)} | |||||
| </React.Fragment> | |||||
| ); | ); | ||||
| })} | })} | ||||
| </TableBody> | </TableBody> | ||||
| @@ -306,7 +306,7 @@ function SearchBox<T extends string>({ | |||||
| <Select | <Select | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeSelectChangeHandler(c.paramName)} | onChange={makeSelectChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName]} | |||||
| value={inputs[c.paramName] ?? "All"} | |||||
| > | > | ||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | <MenuItem value={"All"}>{t("All")}</MenuItem> | ||||
| {c.options.map((option) => ( | {c.options.map((option) => ( | ||||
| @@ -323,7 +323,7 @@ function SearchBox<T extends string>({ | |||||
| <Select | <Select | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeSelectChangeHandler(c.paramName)} | onChange={makeSelectChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName]} | |||||
| value={inputs[c.paramName] ?? "All"} | |||||
| > | > | ||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | <MenuItem value={"All"}>{t("All")}</MenuItem> | ||||
| {c.options.map((option) => ( | {c.options.map((option) => ( | ||||
| @@ -1,84 +1,102 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | 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 { useTranslation } from "react-i18next"; | ||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import { EditNote } from "@mui/icons-material"; | |||||
| import { useRouter } from "next/navigation"; | 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 axiosInstance from "@/app/(main)/axios/axiosInstance"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| type Props = { | |||||
| equipments: EquipmentResult[]; | |||||
| }; | |||||
| interface Props { | |||||
| equipmentDetails: EquipmentDetailResult[]; | |||||
| printerCombo: PrinterCombo[]; | |||||
| } | |||||
| type SearchQuery = Partial<Omit<EquipmentResult, "id">>; | |||||
| type SearchQuery = Partial<Omit<EquipmentDetailResult, "id">>; | |||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||||
| const [filteredEquipments, setFilteredEquipments] = | |||||
| useState<EquipmentResult[]>([]); | |||||
| const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printerCombo }) => { | |||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState<EquipmentDetailResult[]>([]); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const [filterObj, setFilterObj] = useState({}); | |||||
| const { setIsUploading } = useUploadContext(); | |||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| pageNum: 1, | pageNum: 1, | ||||
| pageSize: 10, | pageSize: 10, | ||||
| }); | }); | ||||
| const [filterObj, setFilterObj] = useState({}); | |||||
| const [totalCount, setTotalCount] = useState(0); | const [totalCount, setTotalCount] = useState(0); | ||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => { | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = [ | |||||
| { 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<Map<string | number, EquipmentDetailResult>>(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<string | null>(null); | |||||
| const [selectedEquipmentDetailsModalOpen, setSelectedEquipmentDetailsModalOpen] = useState(false); | |||||
| const filteredPrinters = useMemo(() => { | |||||
| return printerCombo.filter((printer) => { | |||||
| return printer.type === "A4"; | |||||
| }); | |||||
| }, [printerCombo]); | |||||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | undefined>( | |||||
| filteredPrinters.length > 0 ? filteredPrinters[0] : undefined | |||||
| ); | ); | ||||
| const columns = useMemo<Column<EquipmentResult>[]>( | |||||
| useEffect(() => { | |||||
| if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) { | |||||
| setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined); | |||||
| } | |||||
| }, [filteredPrinters, selectedPrinter]); | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||||
| () => [ | () => [ | ||||
| { | { | ||||
| name: "id", | |||||
| label: t("Details"), | |||||
| onClick: onDetailClick, | |||||
| buttonIcon: <EditNote />, | |||||
| }, | |||||
| { | |||||
| 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: <GridDeleteIcon />, | |||||
| onClick: onDeleteClick, | |||||
| label: "設備編號", | |||||
| paramName: "equipmentCode", | |||||
| type: "text", | |||||
| }, | }, | ||||
| ], | ], | ||||
| [filteredEquipments], | |||||
| [], | |||||
| ); | ); | ||||
| interface ApiResponse<T> { | interface ApiResponse<T> { | ||||
| @@ -101,20 +119,19 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||||
| ...filterObj, | ...filterObj, | ||||
| }; | }; | ||||
| try { | try { | ||||
| const response = await axiosInstance.get<ApiResponse<EquipmentResult>>( | |||||
| `${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, | |||||
| const response = await axiosInstance.get<ApiResponse<EquipmentDetailResult>>( | |||||
| `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, | |||||
| { params }, | { params }, | ||||
| ); | ); | ||||
| console.log(response); | |||||
| if (response.status == 200) { | if (response.status == 200) { | ||||
| setFilteredEquipments(response.data.records); | |||||
| setFilteredEquipmentDetails(response.data.records); | |||||
| setTotalCount(response.data.total); | setTotalCount(response.data.total); | ||||
| return response; | return response; | ||||
| } else { | } else { | ||||
| throw "400"; | throw "400"; | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error fetching equipment types:", error); | |||||
| console.error("Error fetching equipment details:", error); | |||||
| throw error; | throw error; | ||||
| } | } | ||||
| }, | }, | ||||
| @@ -125,6 +142,228 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||||
| refetchData(filterObj, pagingController.pageNum, pagingController.pageSize); | refetchData(filterObj, pagingController.pageNum, pagingController.pageSize); | ||||
| }, [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<EquipmentDetailResult[]> => { | |||||
| 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<ApiResponse<EquipmentDetailResult>>( | |||||
| `${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<Column<EquipmentDetailResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| name: "id", | |||||
| label: "", | |||||
| sx: { width: "50px", minWidth: "50px" }, | |||||
| renderCell: (params) => ( | |||||
| <Checkbox | |||||
| checked={checkboxIds.includes(params.id)} | |||||
| onChange={(e) => 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(() => { | const onReset = useCallback(() => { | ||||
| setFilterObj({}); | setFilterObj({}); | ||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | setPagingController({ pageNum: 1, pageSize: 10 }); | ||||
| @@ -138,19 +377,238 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||||
| setFilterObj({ | setFilterObj({ | ||||
| ...query, | ...query, | ||||
| }); | }); | ||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||||
| }} | }} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| /> | /> | ||||
| <SearchResults<EquipmentResult> | |||||
| items={filteredEquipments} | |||||
| <SearchResults<EquipmentDetailResult> | |||||
| items={filteredEquipmentDetails} | |||||
| columns={columns} | columns={columns} | ||||
| setPagingController={setPagingController} | |||||
| pagingController={pagingController} | pagingController={pagingController} | ||||
| setPagingController={setPagingController} | |||||
| totalCount={totalCount} | totalCount={totalCount} | ||||
| isAutoPaging={false} | isAutoPaging={false} | ||||
| /> | /> | ||||
| <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| onClick={() => handleSelectAll(!selectAll)} | |||||
| startIcon={<Checkbox checked={selectAll} />} | |||||
| disabled={isSearching || totalCount === 0} | |||||
| > | |||||
| 選擇全部設備 ({checkboxIds.length} / {totalCount}) | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleViewSelectedQrCodes} | |||||
| disabled={checkboxIds.length === 0} | |||||
| color="primary" | |||||
| > | |||||
| 查看已選擇設備二維碼 ({checkboxIds.length}) | |||||
| </Button> | |||||
| </Box> | |||||
| <Modal | |||||
| open={selectedEquipmentDetailsModalOpen} | |||||
| onClose={handleCloseSelectedEquipmentDetailsModal} | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| alignItems: 'center', | |||||
| justifyContent: 'center', | |||||
| }} | |||||
| > | |||||
| <Card | |||||
| sx={{ | |||||
| position: 'relative', | |||||
| width: '90%', | |||||
| maxWidth: '800px', | |||||
| maxHeight: '90vh', | |||||
| display: 'flex', | |||||
| flexDirection: 'column', | |||||
| outline: 'none', | |||||
| }} | |||||
| > | |||||
| <Box | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| justifyContent: 'space-between', | |||||
| alignItems: 'center', | |||||
| p: 2, | |||||
| borderBottom: 1, | |||||
| borderColor: 'divider', | |||||
| }} | |||||
| > | |||||
| <Typography variant="h6" component="h2"> | |||||
| 已選擇設備 ({selectedEquipmentDetails.length}) | |||||
| </Typography> | |||||
| <IconButton onClick={handleCloseSelectedEquipmentDetailsModal}> | |||||
| <CloseIcon /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| <Box | |||||
| sx={{ | |||||
| flex: 1, | |||||
| overflow: 'auto', | |||||
| p: 2, | |||||
| }} | |||||
| > | |||||
| <TableContainer component={Paper} variant="outlined"> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell> | |||||
| <strong>設備名稱</strong> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <strong>設備描述</strong> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <strong>設備編號</strong> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {selectedEquipmentDetails.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={3} align="center"> | |||||
| 沒有選擇的設備 | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| selectedEquipmentDetails.map((equipmentDetail) => ( | |||||
| <TableRow key={equipmentDetail.id}> | |||||
| <TableCell>{equipmentDetail.code || '-'}</TableCell> | |||||
| <TableCell>{equipmentDetail.description || '-'}</TableCell> | |||||
| <TableCell>{equipmentDetail.equipmentCode || '-'}</TableCell> | |||||
| </TableRow> | |||||
| )) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </Box> | |||||
| <Box | |||||
| sx={{ | |||||
| p: 2, | |||||
| borderTop: 1, | |||||
| borderColor: 'divider', | |||||
| bgcolor: 'background.paper', | |||||
| }} | |||||
| > | |||||
| <Stack direction="row" justifyContent="flex-end" alignItems="center" gap={2}> | |||||
| <Autocomplete<PrinterCombo> | |||||
| options={filteredPrinters} | |||||
| value={selectedPrinter ?? null} | |||||
| onChange={(event, value) => { | |||||
| setSelectedPrinter(value ?? undefined); | |||||
| }} | |||||
| getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| variant="outlined" | |||||
| label="列印機" | |||||
| sx={{ width: 300 }} | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| <TextField | |||||
| variant="outlined" | |||||
| label="列印數量" | |||||
| type="number" | |||||
| value={printQty} | |||||
| onChange={(e) => { | |||||
| const value = parseInt(e.target.value) || 1; | |||||
| setPrintQty(Math.max(1, value)); | |||||
| }} | |||||
| inputProps={{ min: 1 }} | |||||
| sx={{ width: 120 }} | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<PrintIcon />} | |||||
| onClick={handlePrint} | |||||
| disabled={checkboxIds.length === 0 || filteredPrinters.length === 0} | |||||
| color="primary" | |||||
| > | |||||
| 列印 | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<DownloadIcon />} | |||||
| onClick={() => handleDownloadQrCode(checkboxIds)} | |||||
| disabled={checkboxIds.length === 0} | |||||
| color="primary" | |||||
| > | |||||
| 下載二維碼 | |||||
| </Button> | |||||
| </Stack> | |||||
| </Box> | |||||
| </Card> | |||||
| </Modal> | |||||
| <Modal | |||||
| open={previewOpen} | |||||
| onClose={handleClosePreview} | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| alignItems: 'center', | |||||
| justifyContent: 'center', | |||||
| }} | |||||
| > | |||||
| <Card | |||||
| sx={{ | |||||
| position: 'relative', | |||||
| width: '90%', | |||||
| maxWidth: '900px', | |||||
| maxHeight: '90vh', | |||||
| display: 'flex', | |||||
| flexDirection: 'column', | |||||
| outline: 'none', | |||||
| }} | |||||
| > | |||||
| <Box | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| justifyContent: 'flex-end', | |||||
| alignItems: 'center', | |||||
| p: 2, | |||||
| borderBottom: 1, | |||||
| borderColor: 'divider', | |||||
| }} | |||||
| > | |||||
| <IconButton | |||||
| onClick={handleClosePreview} | |||||
| > | |||||
| <CloseIcon /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| <Box | |||||
| sx={{ | |||||
| flex: 1, | |||||
| overflow: 'auto', | |||||
| p: 2, | |||||
| }} | |||||
| > | |||||
| {previewUrl && ( | |||||
| <iframe | |||||
| src={previewUrl} | |||||
| width="100%" | |||||
| height="600px" | |||||
| style={{ | |||||
| border: 'none', | |||||
| }} | |||||
| title="PDF Preview" | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| </Card> | |||||
| </Modal> | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| export default QrCodeHandleEquipmentSearch; | |||||
| export default QrCodeHandleEquipmentSearch; | |||||
| @@ -1,15 +1,19 @@ | |||||
| import React from "react"; | import React from "react"; | ||||
| import QrCodeHandleEquipmentSearch from "./qrCodeHandleEquipmentSearch"; | import QrCodeHandleEquipmentSearch from "./qrCodeHandleEquipmentSearch"; | ||||
| import EquipmentSearchLoading from "../EquipmentSearch/EquipmentSearchLoading"; | import EquipmentSearchLoading from "../EquipmentSearch/EquipmentSearchLoading"; | ||||
| import { fetchAllEquipments } from "@/app/api/settings/equipment"; | |||||
| import { fetchAllEquipmentDetails } from "@/app/api/settings/equipmentDetail"; | |||||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof EquipmentSearchLoading; | Loading: typeof EquipmentSearchLoading; | ||||
| } | } | ||||
| const QrCodeHandleEquipmentSearchWrapper: React.FC & SubComponents = async () => { | const QrCodeHandleEquipmentSearchWrapper: React.FC & SubComponents = async () => { | ||||
| const equipments = await fetchAllEquipments(); | |||||
| return <QrCodeHandleEquipmentSearch equipments={equipments} />; | |||||
| const [equipmentDetails, printerCombo] = await Promise.all([ | |||||
| fetchAllEquipmentDetails(), | |||||
| fetchPrinterCombo(), | |||||
| ]); | |||||
| return <QrCodeHandleEquipmentSearch equipmentDetails={equipmentDetails} printerCombo={printerCombo} />; | |||||
| }; | }; | ||||
| QrCodeHandleEquipmentSearchWrapper.Loading = EquipmentSearchLoading; | QrCodeHandleEquipmentSearchWrapper.Loading = EquipmentSearchLoading; | ||||
| @@ -1,8 +1,9 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useState, ReactNode } from "react"; | |||||
| import { useState, ReactNode, useEffect } from "react"; | |||||
| import { Box, Tabs, Tab } from "@mui/material"; | import { Box, Tabs, Tab } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useSearchParams, useRouter } from "next/navigation"; | |||||
| interface TabPanelProps { | interface TabPanelProps { | ||||
| children?: ReactNode; | children?: ReactNode; | ||||
| @@ -37,10 +38,33 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const { t: tUser } = useTranslation("user"); | const { t: tUser } = useTranslation("user"); | ||||
| const [currentTab, setCurrentTab] = useState(0); | |||||
| const searchParams = useSearchParams(); | |||||
| const router = useRouter(); | |||||
| const getInitialTab = () => { | |||||
| const tab = searchParams.get("tab"); | |||||
| if (tab === "equipment") return 1; | |||||
| if (tab === "user") return 0; | |||||
| return 0; | |||||
| }; | |||||
| const [currentTab, setCurrentTab] = useState(getInitialTab); | |||||
| useEffect(() => { | |||||
| const tab = searchParams.get("tab"); | |||||
| if (tab === "equipment") { | |||||
| setCurrentTab(1); | |||||
| } else if (tab === "user") { | |||||
| setCurrentTab(0); | |||||
| } | |||||
| }, [searchParams]); | |||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | ||||
| setCurrentTab(newValue); | setCurrentTab(newValue); | ||||
| const tabName = newValue === 1 ? "equipment" : "user"; | |||||
| const params = new URLSearchParams(searchParams.toString()); | |||||
| params.set("tab", tabName); | |||||
| router.push(`?${params.toString()}`, { scroll: false }); | |||||
| }; | }; | ||||
| return ( | return ( | ||||
| @@ -374,17 +374,17 @@ | |||||
| "Filter by Status": "按狀態篩選", | "Filter by Status": "按狀態篩選", | ||||
| "All": "全部", | "All": "全部", | ||||
| "General Data": "基本資料", | "General Data": "基本資料", | ||||
| "Repair and Maintenance": "維修和保養", | |||||
| "Repair and Maintenance Status": "維修和保養狀態", | |||||
| "Latest Repair and Maintenance Date": "最新維修和保養日期", | |||||
| "Last Repair and Maintenance Date": "上次維修和保養日期", | |||||
| "Repair and Maintenance Remarks": "維修和保養備註", | |||||
| "Repair and Maintenance": "維護和保養", | |||||
| "Repair and Maintenance Status": "維護和保養狀態", | |||||
| "Latest Repair and Maintenance Date": "最新維護和保養日期", | |||||
| "Last Repair and Maintenance Date": "上次維護和保養日期", | |||||
| "Repair and Maintenance Remarks": "維護和保養備註", | |||||
| "Rows per page": "每頁行數", | "Rows per page": "每頁行數", | ||||
| "Equipment Name": "設備名稱", | "Equipment Name": "設備名稱", | ||||
| "Equipment Code": "設備編號", | "Equipment Code": "設備編號", | ||||
| "Yes": "是", | "Yes": "是", | ||||
| "No": "否", | "No": "否", | ||||
| "Update Equipment Maintenance and Repair": "更新設備的維修和保養", | |||||
| "Update Equipment Maintenance and Repair": "更新設備的維護和保養", | |||||
| "Equipment Information": "設備資訊", | "Equipment Information": "設備資訊", | ||||
| "Loading": "載入中...", | "Loading": "載入中...", | ||||
| "Equipment not found": "找不到設備", | "Equipment not found": "找不到設備", | ||||
| @@ -401,5 +401,6 @@ | |||||
| "Search or select remark": "搜尋或選擇備註", | "Search or select remark": "搜尋或選擇備註", | ||||
| "Edit shop details": "編輯店鋪詳情", | "Edit shop details": "編輯店鋪詳情", | ||||
| "Add Shop to Truck Lane": "新增店鋪至卡車路線", | "Add Shop to Truck Lane": "新增店鋪至卡車路線", | ||||
| "Truck lane code already exists. Please use a different code.": "卡車路線編號已存在,請使用其他編號。" | |||||
| "Truck lane code already exists. Please use a different code.": "卡車路線編號已存在,請使用其他編號。", | |||||
| "MaintenanceEdit": "編輯維護和保養" | |||||
| } | } | ||||