FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1039 lines
37 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useMemo, useState } from "react";
  3. import SearchBox, { Criterion } from "../SearchBox";
  4. import { EquipmentResult } from "@/app/api/settings/equipment";
  5. import { useTranslation } from "react-i18next";
  6. import EquipmentSearchResults, { Column } from "./EquipmentSearchResults";
  7. import { EditNote } from "@mui/icons-material";
  8. import { useRouter } from "next/navigation";
  9. import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api";
  10. import axiosInstance from "@/app/(main)/axios/axiosInstance";
  11. import { arrayToDateTimeString } from "@/app/utils/formatUtil";
  12. import Box from "@mui/material/Box";
  13. import Typography from "@mui/material/Typography";
  14. import IconButton from "@mui/material/IconButton";
  15. import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
  16. import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
  17. import CircularProgress from "@mui/material/CircularProgress";
  18. import TableRow from "@mui/material/TableRow";
  19. import TableCell from "@mui/material/TableCell";
  20. import Collapse from "@mui/material/Collapse";
  21. import Grid from "@mui/material/Grid";
  22. import DeleteIcon from "@mui/icons-material/Delete";
  23. import AddIcon from "@mui/icons-material/Add";
  24. import Button from "@mui/material/Button";
  25. import Dialog from "@mui/material/Dialog";
  26. import DialogTitle from "@mui/material/DialogTitle";
  27. import DialogContent from "@mui/material/DialogContent";
  28. import DialogContentText from "@mui/material/DialogContentText";
  29. import DialogActions from "@mui/material/DialogActions";
  30. import TextField from "@mui/material/TextField";
  31. import Autocomplete from "@mui/material/Autocomplete";
  32. import InputAdornment from "@mui/material/InputAdornment";
  33. type Props = {
  34. equipments: EquipmentResult[];
  35. tabIndex?: number;
  36. };
  37. type SearchQuery = Partial<Omit<EquipmentResult, "id">>;
  38. type SearchParamNames = keyof SearchQuery;
  39. const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
  40. const [filteredEquipments, setFilteredEquipments] =
  41. useState<EquipmentResult[]>([]);
  42. const { t } = useTranslation("common");
  43. const router = useRouter();
  44. const [filterObjByTab, setFilterObjByTab] = useState<Record<number, SearchQuery>>({
  45. 0: {},
  46. 1: {},
  47. });
  48. const [pagingControllerByTab, setPagingControllerByTab] = useState<Record<number, { pageNum: number; pageSize: number }>>({
  49. 0: { pageNum: 1, pageSize: 10 },
  50. 1: { pageNum: 1, pageSize: 10 },
  51. });
  52. const [totalCount, setTotalCount] = useState(0);
  53. const [isLoading, setIsLoading] = useState(true);
  54. const [isReady, setIsReady] = useState(false);
  55. const filterObj = filterObjByTab[tabIndex] || {};
  56. const pagingController = pagingControllerByTab[tabIndex] || { pageNum: 1, pageSize: 10 };
  57. const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set());
  58. const [equipmentDetailsMap, setEquipmentDetailsMap] = useState<Map<string | number, EquipmentResult[]>>(new Map());
  59. const [loadingDetailsMap, setLoadingDetailsMap] = useState<Map<string | number, boolean>>(new Map());
  60. const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  61. const [itemToDelete, setItemToDelete] = useState<{ id: string | number; equipmentId: string | number } | null>(null);
  62. const [deleting, setDeleting] = useState(false);
  63. const [addDialogOpen, setAddDialogOpen] = useState(false);
  64. const [equipmentList, setEquipmentList] = useState<EquipmentResult[]>([]);
  65. const [selectedDescription, setSelectedDescription] = useState<string>("");
  66. const [selectedName, setSelectedName] = useState<string>("");
  67. const [selectedEquipmentCode, setSelectedEquipmentCode] = useState<string>("");
  68. const [equipmentCodePrefix, setEquipmentCodePrefix] = useState<string>("");
  69. const [equipmentCodeNumber, setEquipmentCodeNumber] = useState<string>("");
  70. const [isExistingCombination, setIsExistingCombination] = useState(false);
  71. const [loadingEquipments, setLoadingEquipments] = useState(false);
  72. const [saving, setSaving] = useState(false);
  73. useEffect(() => {
  74. const checkReady = () => {
  75. try {
  76. const token = localStorage.getItem("accessToken");
  77. const hasAuthHeader = axiosInstance.defaults.headers?.common?.Authorization ||
  78. axiosInstance.defaults.headers?.Authorization;
  79. if (token && hasAuthHeader) {
  80. setIsReady(true);
  81. } else if (token) {
  82. setTimeout(checkReady, 50);
  83. } else {
  84. setTimeout(checkReady, 100);
  85. }
  86. } catch (e) {
  87. console.warn("localStorage unavailable", e);
  88. }
  89. };
  90. const timer = setTimeout(checkReady, 100);
  91. return () => clearTimeout(timer);
  92. }, []);
  93. const displayDateTime = useCallback((dateValue: string | Date | number[] | null | undefined): string => {
  94. if (!dateValue) return "-";
  95. if (Array.isArray(dateValue)) {
  96. return arrayToDateTimeString(dateValue);
  97. }
  98. if (typeof dateValue === "string") {
  99. return dateValue;
  100. }
  101. return String(dateValue);
  102. }, []);
  103. const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => {
  104. if (tabIndex === 1) {
  105. return [
  106. {
  107. label: "設備名稱/設備編號",
  108. paramName: "equipmentCode",
  109. type: "text"
  110. },
  111. {
  112. label: t("Repair and Maintenance Status"),
  113. paramName: "repairAndMaintenanceStatus",
  114. type: "select",
  115. options: ["正常使用中", "正在維護中"]
  116. },
  117. ];
  118. }
  119. return [
  120. { label: "設備編號", paramName: "code", type: "text" },
  121. ];
  122. }, [t, tabIndex]);
  123. const onMaintenanceEditClick = useCallback(
  124. (equipment: EquipmentResult) => {
  125. router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`);
  126. },
  127. [router],
  128. );
  129. const onDeleteClick = useCallback(
  130. (equipment: EquipmentResult) => {},
  131. [router],
  132. );
  133. const fetchEquipmentDetailsByEquipmentId = useCallback(async (equipmentId: string | number) => {
  134. setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, true));
  135. try {
  136. const response = await axiosInstance.get<{
  137. records: EquipmentResult[];
  138. total: number;
  139. }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byEquipmentId/${equipmentId}`);
  140. if (response.status === 200) {
  141. setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, response.data.records || []));
  142. }
  143. } catch (error) {
  144. console.error("Error fetching equipment details:", error);
  145. setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, []));
  146. } finally {
  147. setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, false));
  148. }
  149. }, []);
  150. const handleDeleteClick = useCallback((detailId: string | number, equipmentId: string | number) => {
  151. setItemToDelete({ id: detailId, equipmentId });
  152. setDeleteDialogOpen(true);
  153. }, []);
  154. const handleDeleteConfirm = useCallback(async () => {
  155. if (!itemToDelete) return;
  156. setDeleting(true);
  157. try {
  158. const response = await axiosInstance.delete(
  159. `${NEXT_PUBLIC_API_URL}/EquipmentDetail/delete/${itemToDelete.id}`
  160. );
  161. if (response.status === 200 || response.status === 204) {
  162. setEquipmentDetailsMap(prev => {
  163. const newMap = new Map(prev);
  164. const currentDetails = newMap.get(itemToDelete.equipmentId) || [];
  165. const updatedDetails = currentDetails.filter(detail => detail.id !== itemToDelete.id);
  166. newMap.set(itemToDelete.equipmentId, updatedDetails);
  167. return newMap;
  168. });
  169. }
  170. } catch (error) {
  171. console.error("Error deleting equipment detail:", error);
  172. alert("刪除失敗,請稍後再試");
  173. } finally {
  174. setDeleting(false);
  175. setDeleteDialogOpen(false);
  176. setItemToDelete(null);
  177. }
  178. }, [itemToDelete]);
  179. const handleDeleteCancel = useCallback(() => {
  180. setDeleteDialogOpen(false);
  181. setItemToDelete(null);
  182. }, []);
  183. const fetchEquipmentList = useCallback(async () => {
  184. setLoadingEquipments(true);
  185. try {
  186. const response = await axiosInstance.get<{
  187. records: EquipmentResult[];
  188. total: number;
  189. }>(`${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, {
  190. params: {
  191. pageNum: 1,
  192. pageSize: 1000,
  193. },
  194. });
  195. if (response.status === 200) {
  196. setEquipmentList(response.data.records || []);
  197. }
  198. } catch (error) {
  199. console.error("Error fetching equipment list:", error);
  200. setEquipmentList([]);
  201. } finally {
  202. setLoadingEquipments(false);
  203. }
  204. }, []);
  205. const handleAddClick = useCallback(() => {
  206. setAddDialogOpen(true);
  207. fetchEquipmentList();
  208. }, [fetchEquipmentList]);
  209. const handleAddDialogClose = useCallback(() => {
  210. setAddDialogOpen(false);
  211. setSelectedDescription("");
  212. setSelectedName("");
  213. setSelectedEquipmentCode("");
  214. setEquipmentCodePrefix("");
  215. setEquipmentCodeNumber("");
  216. setIsExistingCombination(false);
  217. }, []);
  218. const availableDescriptions = useMemo(() => {
  219. const descriptions = equipmentList
  220. .map((eq) => eq.description)
  221. .filter((desc): desc is string => Boolean(desc));
  222. return Array.from(new Set(descriptions));
  223. }, [equipmentList]);
  224. const availableNames = useMemo(() => {
  225. const names = equipmentList
  226. .map((eq) => eq.name)
  227. .filter((name): name is string => Boolean(name));
  228. return Array.from(new Set(names));
  229. }, [equipmentList]);
  230. useEffect(() => {
  231. const checkAndGenerateEquipmentCode = async () => {
  232. if (!selectedDescription || !selectedName) {
  233. setIsExistingCombination(false);
  234. setSelectedEquipmentCode("");
  235. return;
  236. }
  237. const equipmentCode = `${selectedDescription}-${selectedName}`;
  238. const existingEquipment = equipmentList.find((eq) => eq.code === equipmentCode);
  239. if (existingEquipment) {
  240. setIsExistingCombination(true);
  241. try {
  242. const existingDetailsResponse = await axiosInstance.get<{
  243. records: EquipmentResult[];
  244. total: number;
  245. }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`);
  246. let newEquipmentCode = "";
  247. if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) {
  248. const equipmentCodePatterns = existingDetailsResponse.data.records
  249. .map((detail) => {
  250. if (!detail.equipmentCode) return null;
  251. const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/);
  252. if (match) {
  253. const originalNumber = match[2];
  254. return {
  255. prefix: match[1],
  256. number: parseInt(match[2], 10),
  257. paddingLength: originalNumber.length
  258. };
  259. }
  260. return null;
  261. })
  262. .filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null);
  263. if (equipmentCodePatterns.length > 0) {
  264. const prefix = equipmentCodePatterns[0].prefix;
  265. const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number));
  266. const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength));
  267. const nextNumber = maxEquipmentCodeNumber + 1;
  268. newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`;
  269. } else {
  270. newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
  271. }
  272. } else {
  273. newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
  274. }
  275. setSelectedEquipmentCode(newEquipmentCode);
  276. } catch (error) {
  277. console.error("Error checking existing equipment details:", error);
  278. setIsExistingCombination(false);
  279. setSelectedEquipmentCode("");
  280. }
  281. } else {
  282. setIsExistingCombination(false);
  283. setSelectedEquipmentCode("");
  284. setEquipmentCodePrefix("");
  285. setEquipmentCodeNumber("");
  286. }
  287. };
  288. checkAndGenerateEquipmentCode();
  289. }, [selectedDescription, selectedName, equipmentList]);
  290. useEffect(() => {
  291. const generateNumberForPrefix = async () => {
  292. if (isExistingCombination || !equipmentCodePrefix) {
  293. return;
  294. }
  295. if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) {
  296. setEquipmentCodeNumber("");
  297. setSelectedEquipmentCode(equipmentCodePrefix);
  298. return;
  299. }
  300. try {
  301. const response = await axiosInstance.get<{
  302. records: EquipmentResult[];
  303. total: number;
  304. }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, {
  305. params: {
  306. pageNum: 1,
  307. pageSize: 1000,
  308. },
  309. });
  310. let maxNumber = 0;
  311. let maxPaddingLength = 2;
  312. if (response.data.records && response.data.records.length > 0) {
  313. const matchingCodes = response.data.records
  314. .map((detail) => {
  315. if (!detail.equipmentCode) return null;
  316. const match = detail.equipmentCode.match(new RegExp(`^${equipmentCodePrefix}(\\d+)$`));
  317. if (match) {
  318. const numberStr = match[1];
  319. return {
  320. number: parseInt(numberStr, 10),
  321. paddingLength: numberStr.length
  322. };
  323. }
  324. return null;
  325. })
  326. .filter((item): item is { number: number; paddingLength: number } => item !== null);
  327. if (matchingCodes.length > 0) {
  328. maxNumber = Math.max(...matchingCodes.map(c => c.number));
  329. maxPaddingLength = Math.max(...matchingCodes.map(c => c.paddingLength));
  330. }
  331. }
  332. const nextNumber = maxNumber + 1;
  333. const numberStr = String(nextNumber).padStart(maxPaddingLength, '0');
  334. setEquipmentCodeNumber(numberStr);
  335. setSelectedEquipmentCode(`${equipmentCodePrefix}${numberStr}`);
  336. } catch (error) {
  337. console.error("Error generating equipment code number:", error);
  338. setEquipmentCodeNumber("");
  339. setSelectedEquipmentCode(equipmentCodePrefix);
  340. }
  341. };
  342. generateNumberForPrefix();
  343. }, [equipmentCodePrefix, isExistingCombination]);
  344. const handleToggleExpand = useCallback(
  345. (id: string | number, code: string) => {
  346. setExpandedRows(prev => {
  347. const newSet = new Set(prev);
  348. if (newSet.has(id)) {
  349. newSet.delete(id);
  350. } else {
  351. newSet.add(id);
  352. if (!equipmentDetailsMap.has(id)) {
  353. fetchEquipmentDetailsByEquipmentId(id);
  354. }
  355. }
  356. return newSet;
  357. });
  358. },
  359. [equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]
  360. );
  361. const generalDataColumns = useMemo<Column<EquipmentResult>[]>(
  362. () => [
  363. {
  364. name: "code",
  365. label: "設備編號",
  366. renderCell: (item) => (
  367. <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
  368. <IconButton
  369. size="small"
  370. onClick={(e) => {
  371. e.stopPropagation();
  372. handleToggleExpand(item.id, item.code);
  373. }}
  374. sx={{ padding: 0.5 }}
  375. >
  376. {expandedRows.has(item.id) ? (
  377. <KeyboardArrowUpIcon fontSize="small" />
  378. ) : (
  379. <KeyboardArrowDownIcon fontSize="small" />
  380. )}
  381. </IconButton>
  382. <Typography>{item.code}</Typography>
  383. </Box>
  384. ),
  385. },
  386. ],
  387. [t, handleToggleExpand, expandedRows],
  388. );
  389. const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>(
  390. () => [
  391. {
  392. name: "id",
  393. label: "編輯",
  394. onClick: onMaintenanceEditClick,
  395. buttonIcon: <EditNote />,
  396. align: "left",
  397. headerAlign: "left",
  398. sx: { width: "60px", minWidth: "60px" },
  399. },
  400. {
  401. name: "code",
  402. label: "設備名稱",
  403. align: "left",
  404. headerAlign: "left",
  405. sx: { width: "200px", minWidth: "200px" },
  406. },
  407. {
  408. name: "equipmentCode",
  409. label: "設備編號",
  410. align: "left",
  411. headerAlign: "left",
  412. sx: { width: "150px", minWidth: "150px" },
  413. renderCell: (item) => {
  414. return item.equipmentCode || "-";
  415. },
  416. },
  417. {
  418. name: "repairAndMaintenanceStatus",
  419. label: t("Repair and Maintenance Status"),
  420. align: "left",
  421. headerAlign: "left",
  422. sx: { width: "150px", minWidth: "150px" },
  423. renderCell: (item) => {
  424. const status = item.repairAndMaintenanceStatus;
  425. if (status === 1 || status === true) {
  426. return (
  427. <Typography sx={{ color: "red", fontWeight: 500 }}>
  428. 正在維護中
  429. </Typography>
  430. );
  431. } else if (status === 0 || status === false) {
  432. return (
  433. <Typography sx={{ color: "green", fontWeight: 500 }}>
  434. 正常使用中
  435. </Typography>
  436. );
  437. }
  438. return "-";
  439. },
  440. },
  441. {
  442. name: "latestRepairAndMaintenanceDate",
  443. label: t("Latest Repair and Maintenance Date"),
  444. align: "left",
  445. headerAlign: "left",
  446. sx: { width: "200px", minWidth: "200px" },
  447. renderCell: (item) => displayDateTime(item.latestRepairAndMaintenanceDate),
  448. },
  449. {
  450. name: "lastRepairAndMaintenanceDate",
  451. label: t("Last Repair and Maintenance Date"),
  452. align: "left",
  453. headerAlign: "left",
  454. sx: { width: "200px", minWidth: "200px" },
  455. renderCell: (item) => displayDateTime(item.lastRepairAndMaintenanceDate),
  456. },
  457. {
  458. name: "repairAndMaintenanceRemarks",
  459. label: t("Repair and Maintenance Remarks"),
  460. align: "left",
  461. headerAlign: "left",
  462. sx: { width: "200px", minWidth: "200px" },
  463. },
  464. ],
  465. [onMaintenanceEditClick, t, displayDateTime],
  466. );
  467. const columns = useMemo(() => {
  468. return tabIndex === 1 ? repairMaintenanceColumns : generalDataColumns;
  469. }, [tabIndex, repairMaintenanceColumns, generalDataColumns]);
  470. interface ApiResponse<T> {
  471. records: T[];
  472. total: number;
  473. }
  474. const refetchData = useCallback(
  475. async (filterObj: SearchQuery) => {
  476. const token = localStorage.getItem("accessToken");
  477. const hasAuthHeader = axiosInstance.defaults.headers?.common?.Authorization ||
  478. axiosInstance.defaults.headers?.Authorization;
  479. if (!token || !hasAuthHeader) {
  480. console.warn("Token or auth header not ready, skipping API call");
  481. setIsLoading(false);
  482. return;
  483. }
  484. setIsLoading(true);
  485. const transformedFilter: any = { ...filterObj };
  486. if (tabIndex === 1 && transformedFilter.equipmentCode) {
  487. transformedFilter.code = transformedFilter.equipmentCode;
  488. }
  489. if (transformedFilter.repairAndMaintenanceStatus) {
  490. if (transformedFilter.repairAndMaintenanceStatus === "正常使用中") {
  491. transformedFilter.repairAndMaintenanceStatus = false;
  492. } else if (transformedFilter.repairAndMaintenanceStatus === "正在維護中") {
  493. transformedFilter.repairAndMaintenanceStatus = true;
  494. } else if (transformedFilter.repairAndMaintenanceStatus === "All") {
  495. delete transformedFilter.repairAndMaintenanceStatus;
  496. }
  497. }
  498. const params = {
  499. pageNum: pagingController.pageNum,
  500. pageSize: pagingController.pageSize,
  501. ...transformedFilter,
  502. };
  503. try {
  504. const endpoint = tabIndex === 1
  505. ? `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`
  506. : `${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`;
  507. const response = await axiosInstance.get<ApiResponse<EquipmentResult>>(
  508. endpoint,
  509. { params },
  510. );
  511. console.log("API Response:", response);
  512. console.log("Records:", response.data.records);
  513. console.log("Total:", response.data.total);
  514. if (response.status == 200) {
  515. setFilteredEquipments(response.data.records || []);
  516. setTotalCount(response.data.total || 0);
  517. } else {
  518. throw "400";
  519. }
  520. } catch (error) {
  521. console.error("Error fetching equipment types:", error);
  522. setFilteredEquipments([]);
  523. setTotalCount(0);
  524. } finally {
  525. setIsLoading(false);
  526. }
  527. },
  528. [pagingController.pageNum, pagingController.pageSize, tabIndex],
  529. );
  530. useEffect(() => {
  531. if (isReady) {
  532. refetchData(filterObj);
  533. }
  534. }, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]);
  535. const onReset = useCallback(() => {
  536. setFilterObjByTab(prev => ({
  537. ...prev,
  538. [tabIndex]: {},
  539. }));
  540. setPagingControllerByTab(prev => ({
  541. ...prev,
  542. [tabIndex]: {
  543. pageNum: 1,
  544. pageSize: prev[tabIndex]?.pageSize || 10,
  545. },
  546. }));
  547. }, [tabIndex]);
  548. const handleSaveEquipmentDetail = useCallback(async () => {
  549. if (!selectedName || !selectedDescription) {
  550. return;
  551. }
  552. if (!isExistingCombination) {
  553. if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) {
  554. alert("請輸入3個大寫英文字母作為設備編號前綴");
  555. return;
  556. }
  557. if (!equipmentCodeNumber) {
  558. alert("設備編號生成中,請稍候");
  559. return;
  560. }
  561. }
  562. setSaving(true);
  563. try {
  564. const equipmentCode = `${selectedDescription}-${selectedName}`;
  565. let equipment = equipmentList.find((eq) => eq.code === equipmentCode);
  566. let equipmentId: string | number;
  567. if (!equipment) {
  568. const equipmentResponse = await axiosInstance.post<EquipmentResult>(
  569. `${NEXT_PUBLIC_API_URL}/Equipment/save`,
  570. {
  571. code: equipmentCode,
  572. name: selectedName,
  573. description: selectedDescription,
  574. id: null,
  575. }
  576. );
  577. equipment = equipmentResponse.data;
  578. equipmentId = equipment.id;
  579. } else {
  580. equipmentId = equipment.id;
  581. }
  582. const existingDetailsResponse = await axiosInstance.get<{
  583. records: EquipmentResult[];
  584. total: number;
  585. }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`);
  586. let newName = "1號";
  587. let newEquipmentCode = "";
  588. if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) {
  589. const numbers = existingDetailsResponse.data.records
  590. .map((detail) => {
  591. const match = detail.name?.match(/(\d+)號/);
  592. return match ? parseInt(match[1], 10) : 0;
  593. })
  594. .filter((num) => num > 0);
  595. if (numbers.length > 0) {
  596. const maxNumber = Math.max(...numbers);
  597. newName = `${maxNumber + 1}號`;
  598. }
  599. if (isExistingCombination) {
  600. const equipmentCodePatterns = existingDetailsResponse.data.records
  601. .map((detail) => {
  602. if (!detail.equipmentCode) return null;
  603. const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/);
  604. if (match) {
  605. const originalNumber = match[2];
  606. return {
  607. prefix: match[1],
  608. number: parseInt(match[2], 10),
  609. paddingLength: originalNumber.length
  610. };
  611. }
  612. return null;
  613. })
  614. .filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null);
  615. if (equipmentCodePatterns.length > 0) {
  616. const prefix = equipmentCodePatterns[0].prefix;
  617. const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number));
  618. const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength));
  619. const nextNumber = maxEquipmentCodeNumber + 1;
  620. newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`;
  621. } else {
  622. newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
  623. }
  624. } else {
  625. if (isExistingCombination) {
  626. newEquipmentCode = selectedEquipmentCode;
  627. } else {
  628. newEquipmentCode = `${equipmentCodePrefix}${equipmentCodeNumber}`;
  629. }
  630. }
  631. } else {
  632. if (isExistingCombination) {
  633. newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
  634. } else {
  635. newEquipmentCode = `${equipmentCodePrefix}${equipmentCodeNumber}`;
  636. }
  637. }
  638. const detailCode = `${equipmentCode}-${newName}`;
  639. await axiosInstance.post<EquipmentResult>(
  640. `${NEXT_PUBLIC_API_URL}/EquipmentDetail/save`,
  641. {
  642. code: detailCode,
  643. name: newName,
  644. description: equipmentCode,
  645. equipmentCode: newEquipmentCode,
  646. id: null,
  647. equipmentTypeId: equipmentId,
  648. repairAndMaintenanceStatus: false,
  649. }
  650. );
  651. handleAddDialogClose();
  652. if (tabIndex === 0) {
  653. await refetchData(filterObj);
  654. if (equipmentDetailsMap.has(equipmentId)) {
  655. await fetchEquipmentDetailsByEquipmentId(equipmentId);
  656. }
  657. }
  658. alert("新增成功");
  659. } catch (error: any) {
  660. console.error("Error saving equipment detail:", error);
  661. const errorMessage = error.response?.data?.message || error.message || "保存失敗,請稍後再試";
  662. alert(errorMessage);
  663. } finally {
  664. setSaving(false);
  665. }
  666. }, [selectedName, selectedDescription, selectedEquipmentCode, equipmentCodePrefix, equipmentCodeNumber, isExistingCombination, equipmentList, refetchData, filterObj, handleAddDialogClose, tabIndex, equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]);
  667. const renderExpandedRow = useCallback((item: EquipmentResult): React.ReactNode => {
  668. if (tabIndex !== 0) {
  669. return null;
  670. }
  671. const details = equipmentDetailsMap.get(item.id) || [];
  672. const isLoading = loadingDetailsMap.get(item.id) || false;
  673. return (
  674. <TableRow key={`expanded-${item.id}`}>
  675. <TableCell colSpan={columns.length} sx={{ py: 0, border: 0 }}>
  676. <Collapse in={expandedRows.has(item.id)} timeout="auto" unmountOnExit>
  677. <Box sx={{ margin: 2 }}>
  678. {isLoading ? (
  679. <Box sx={{ display: "flex", alignItems: "center", gap: 2, p: 2 }}>
  680. <CircularProgress size={20} />
  681. <Typography>載入中...</Typography>
  682. </Box>
  683. ) : details.length === 0 ? (
  684. <Typography sx={{ p: 2 }}>無相關設備詳細資料</Typography>
  685. ) : (
  686. <Box>
  687. <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: "bold" }}>
  688. 設備詳細資料 (設備編號: {item.code})
  689. </Typography>
  690. <Grid container spacing={2}>
  691. {details.map((detail) => (
  692. <Grid item xs={6} key={detail.id}>
  693. <Box
  694. sx={{
  695. p: 2,
  696. border: "1px solid",
  697. borderColor: "divider",
  698. borderRadius: 1,
  699. height: "100%",
  700. position: "relative",
  701. }}
  702. >
  703. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 1 }}>
  704. <Typography variant="body2" sx={{ fontWeight: 500 }}>
  705. 編號: {detail.code || "-"}
  706. </Typography>
  707. <IconButton
  708. size="small"
  709. color="error"
  710. onClick={() => handleDeleteClick(detail.id, item.id)}
  711. sx={{ ml: 1 }}
  712. >
  713. <DeleteIcon fontSize="small" />
  714. </IconButton>
  715. </Box>
  716. {detail.name && (
  717. <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
  718. 名稱: {detail.name}
  719. </Typography>
  720. )}
  721. {detail.description && (
  722. <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
  723. 描述: {detail.description}
  724. </Typography>
  725. )}
  726. {detail.equipmentCode && (
  727. <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
  728. 設備編號: {detail.equipmentCode}
  729. </Typography>
  730. )}
  731. </Box>
  732. </Grid>
  733. ))}
  734. </Grid>
  735. </Box>
  736. )}
  737. </Box>
  738. </Collapse>
  739. </TableCell>
  740. </TableRow>
  741. );
  742. }, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]);
  743. return (
  744. <>
  745. <SearchBox
  746. criteria={searchCriteria}
  747. onSearch={(query) => {
  748. setFilterObjByTab(prev => {
  749. const newState = { ...prev };
  750. newState[tabIndex] = query as unknown as SearchQuery;
  751. return newState;
  752. });
  753. }}
  754. onReset={onReset}
  755. />
  756. {tabIndex === 0 && (
  757. <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
  758. <Typography variant="h6" component="h2">
  759. 設備編號
  760. </Typography>
  761. <Button
  762. variant="contained"
  763. startIcon={<AddIcon />}
  764. onClick={handleAddClick}
  765. color="primary"
  766. >
  767. 新增
  768. </Button>
  769. </Box>
  770. )}
  771. <Box sx={{
  772. "& .MuiTableContainer-root": {
  773. overflowY: "auto",
  774. "&::-webkit-scrollbar": {
  775. width: "17px"
  776. }
  777. }
  778. }}>
  779. <EquipmentSearchResults<EquipmentResult>
  780. items={filteredEquipments}
  781. columns={columns}
  782. setPagingController={(newController) => {
  783. setPagingControllerByTab(prev => {
  784. const newState = { ...prev };
  785. newState[tabIndex] = typeof newController === 'function'
  786. ? newController(prev[tabIndex] || { pageNum: 1, pageSize: 10 })
  787. : newController;
  788. return newState;
  789. });
  790. }}
  791. pagingController={pagingController}
  792. totalCount={totalCount}
  793. isAutoPaging={false}
  794. renderExpandedRow={renderExpandedRow}
  795. hideHeader={tabIndex === 0}
  796. />
  797. </Box>
  798. {/* Delete Confirmation Dialog */}
  799. {deleteDialogOpen && (
  800. <Dialog
  801. open={deleteDialogOpen}
  802. onClose={handleDeleteCancel}
  803. aria-labelledby="delete-dialog-title"
  804. aria-describedby="delete-dialog-description"
  805. >
  806. <DialogTitle id="delete-dialog-title">
  807. 確認刪除
  808. </DialogTitle>
  809. <DialogContent>
  810. <DialogContentText id="delete-dialog-description">
  811. 您確定要刪除此設備詳細資料嗎?此操作無法復原。
  812. </DialogContentText>
  813. </DialogContent>
  814. <DialogActions>
  815. <Button onClick={handleDeleteCancel} disabled={deleting}>
  816. 取消
  817. </Button>
  818. <Button onClick={handleDeleteConfirm} color="error" disabled={deleting} autoFocus>
  819. {deleting ? "刪除中..." : "刪除"}
  820. </Button>
  821. </DialogActions>
  822. </Dialog>
  823. )}
  824. {/* Add Equipment Detail Dialog */}
  825. <Dialog
  826. open={addDialogOpen}
  827. onClose={handleAddDialogClose}
  828. aria-labelledby="add-dialog-title"
  829. maxWidth="sm"
  830. fullWidth
  831. >
  832. <DialogTitle id="add-dialog-title">
  833. 新增設備詳細資料
  834. </DialogTitle>
  835. <DialogContent>
  836. <Box sx={{ pt: 2 }}>
  837. <Autocomplete
  838. freeSolo
  839. options={availableDescriptions}
  840. value={selectedDescription || null}
  841. onChange={(event, newValue) => {
  842. setSelectedDescription(newValue || '');
  843. }}
  844. onInputChange={(event, newInputValue) => {
  845. setSelectedDescription(newInputValue);
  846. }}
  847. loading={loadingEquipments}
  848. disabled={loadingEquipments || saving}
  849. renderInput={(params) => (
  850. <TextField
  851. {...params}
  852. label="種類"
  853. placeholder="選擇或輸入種類"
  854. />
  855. )}
  856. sx={{ mb: 2 }}
  857. />
  858. <Autocomplete
  859. freeSolo
  860. options={availableNames}
  861. value={selectedName || null}
  862. onChange={(event, newValue) => {
  863. setSelectedName(newValue || '');
  864. }}
  865. onInputChange={(event, newInputValue) => {
  866. setSelectedName(newInputValue);
  867. }}
  868. loading={loadingEquipments}
  869. disabled={loadingEquipments || saving}
  870. componentsProps={{
  871. popper: {
  872. placement: 'bottom-start',
  873. modifiers: [
  874. {
  875. name: 'flip',
  876. enabled: false,
  877. },
  878. {
  879. name: 'preventOverflow',
  880. enabled: true,
  881. },
  882. ],
  883. },
  884. }}
  885. renderInput={(params) => (
  886. <TextField
  887. {...params}
  888. label="名稱"
  889. placeholder="選擇或輸入名稱"
  890. />
  891. )}
  892. />
  893. <Box sx={{ mt: 2 }}>
  894. <TextField
  895. fullWidth
  896. label="設備編號"
  897. value={isExistingCombination ? selectedEquipmentCode : equipmentCodePrefix}
  898. onChange={(e) => {
  899. if (!isExistingCombination) {
  900. const input = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3);
  901. setEquipmentCodePrefix(input);
  902. }
  903. }}
  904. disabled={isExistingCombination || loadingEquipments || saving}
  905. placeholder={isExistingCombination ? "自動生成" : "輸入3個大寫英文字母"}
  906. required={!isExistingCombination}
  907. InputProps={{
  908. endAdornment: !isExistingCombination && equipmentCodeNumber ? (
  909. <InputAdornment position="end">
  910. <Typography
  911. sx={{
  912. color: 'text.secondary',
  913. fontSize: '1rem',
  914. fontWeight: 500,
  915. minWidth: '30px',
  916. textAlign: 'right',
  917. }}
  918. >
  919. {equipmentCodeNumber}
  920. </Typography>
  921. </InputAdornment>
  922. ) : null,
  923. }}
  924. helperText={!isExistingCombination && equipmentCodePrefix.length > 0 && equipmentCodePrefix.length !== 3
  925. ? "必須輸入3個大寫英文字母"
  926. : !isExistingCombination && equipmentCodePrefix.length === 3 && !/^[A-Z]{3}$/.test(equipmentCodePrefix)
  927. ? "必須是大寫英文字母"
  928. : ""}
  929. error={!isExistingCombination && equipmentCodePrefix.length > 0 && (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix))}
  930. />
  931. </Box>
  932. </Box>
  933. </DialogContent>
  934. <DialogActions>
  935. <Button onClick={handleAddDialogClose} disabled={saving}>
  936. 取消
  937. </Button>
  938. <Button
  939. onClick={handleSaveEquipmentDetail}
  940. variant="contained"
  941. disabled={!selectedName || !selectedDescription || (!isExistingCombination && !selectedEquipmentCode) || loadingEquipments || saving}
  942. >
  943. {saving ? "保存中..." : "新增"}
  944. </Button>
  945. </DialogActions>
  946. </Dialog>
  947. </>
  948. );
  949. };
  950. export default EquipmentSearch;